/* 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 flags = require("resource://devtools/shared/flags.js"); const { l10n } = require("resource://devtools/shared/inspector/css-logic.js"); const { style: { ELEMENT_STYLE }, } = require("resource://devtools/shared/constants.js"); const { PSEUDO_CLASSES, ELEMENT_SPECIFIC_PSEUDO_CLASSES, } = require("resource://devtools/shared/css/constants.js"); const OutputParser = require("resource://devtools/client/shared/output-parser.js"); const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); const ElementStyle = require("resource://devtools/client/inspector/rules/models/element-style.js"); const RuleEditor = require("resource://devtools/client/inspector/rules/views/rule-editor.js"); const RegisteredPropertyEditor = require("resource://devtools/client/inspector/rules/views/registered-property-editor.js"); const TooltipsOverlay = require("resource://devtools/client/inspector/shared/tooltips-overlay.js"); const { createChild, } = require("resource://devtools/client/inspector/shared/utils.js"); const { debounce } = require("resource://devtools/shared/debounce.js"); const EventEmitter = require("resource://devtools/shared/event-emitter.js"); const CssLogic = require("resource://devtools/shared/inspector/css-logic.js"); loader.lazyRequireGetter( this, ["flashElementOn", "flashElementOff"], "resource://devtools/client/inspector/markup/utils.js", true ); loader.lazyRequireGetter( this, "ClassListPreviewer", "resource://devtools/client/inspector/rules/views/class-list-previewer.js" ); loader.lazyRequireGetter( this, ["getNodeInfo", "getNodeCompatibilityInfo", "getRuleFromNode"], "resource://devtools/client/inspector/rules/utils/utils.js", true ); loader.lazyRequireGetter( this, "StyleInspectorMenu", "resource://devtools/client/inspector/shared/style-inspector-menu.js" ); loader.lazyRequireGetter( this, "AutocompletePopup", "resource://devtools/client/shared/autocomplete-popup.js" ); loader.lazyRequireGetter( this, "KeyShortcuts", "resource://devtools/client/shared/key-shortcuts.js" ); loader.lazyRequireGetter( this, "clipboardHelper", "resource://devtools/shared/platform/clipboard.js" ); loader.lazyRequireGetter( this, "openContentLink", "resource://devtools/client/shared/link.js", true ); const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AppConstants: "resource://gre/modules/AppConstants.sys.mjs", }); const HTML_NS = "http://www.w3.org/1999/xhtml"; const PREF_UA_STYLES = "devtools.inspector.showUserAgentStyles"; const PREF_DEFAULT_COLOR_UNIT = "devtools.defaultColorUnit"; const PREF_DRAGGABLE = "devtools.inspector.draggable_properties"; const PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER = "devtools.inspector.rule-view.focusNextOnEnter"; const PREF_CSS_EXPLAINERS = "devtools.inspector.css-explainers"; const FILTER_CHANGED_TIMEOUT = 150; // Removes the flash-out class from an element after 1 second (100ms in tests so they // don't take too long to run). const PROPERTY_FLASHING_DURATION = flags.testing ? 100 : 1000; // This is used to parse user input when filtering. const FILTER_PROP_RE = /\s*([^:\s]*)\s*:\s*(.*?)\s*;?$/; // This is used to parse the filter search value to see if the filter // should be strict or not const FILTER_STRICT_RE = /\s*`(.*?)`\s*$/; const RULE_VIEW_HEADER_CLASSNAME = "ruleview-header"; // List of all container IDs, order by typical order of display const PSEUDO_ELEMENTS_CONTAINER_ID = "pseudo-elements-container"; const ELEMENT_CONTAINER_ID = "element-container"; const REGISTERED_PROPERTIES_CONTAINER_ID = "registered-properties-container"; const POSITION_TRY_CONTAINER_ID = "position-try-container"; /** * Our model looks like this: * * ElementStyle: * Responsible for keeping track of which properties are overridden. * Maintains a list of Rule objects that apply to the element. * Rule: * Manages a single style declaration or rule. * Responsible for applying changes to the properties in a rule. * Maintains a list of TextProperty objects. * TextProperty: * Manages a single property from the authoredText attribute of the * relevant declaration. * Maintains a list of computed properties that come from this * property declaration. * Changes to the TextProperty are sent to its related Rule for * application. * * View hierarchy mostly follows the model hierarchy. * * CssRuleView: * Owns an ElementStyle and creates a list of RuleEditors for its * Rules. * RuleEditor: * Owns a Rule object and creates a list of TextPropertyEditors * for its TextProperties. * Manages creation of new text properties. * TextPropertyEditor: * Owns a TextProperty object. * Manages changes to the TextProperty. * Can be expanded to display computed properties. * Can mark a property disabled or enabled. */ /** * CssRuleView is a view of the style rules and declarations that * apply to a given element. After construction, the 'element' * property will be available with the user interface. */ class CssRuleView extends EventEmitter { /** * @param {Inspector} inspector * Inspector toolbox panel * @param {Document} document * The document that will contain the rule view. * @param {object} store * The CSS rule view can use this object to store metadata * that might outlast the rule view, particularly the current * set of disabled properties. */ constructor(inspector, document, store) { super(); this.inspector = inspector; this.cssProperties = inspector.cssProperties; this.styleDocument = document; this.styleWindow = this.styleDocument.defaultView; this.store = store || { expandedUnusedCustomCssPropertiesRuleActorIds: new Set(), }; // Allow tests to override debouncing behavior, as this can cause intermittents. this.debounce = debounce; // Used by TextPropertyEditor this.outputParser = new OutputParser(document, this.cssProperties); this.addNewRule = this.addNewRule.bind(this); this.refreshPanel = this.refreshPanel.bind(this); this.#abortController = new this.styleWindow.AbortController(); const { signal } = this.#abortController; const baseEventConfig = { signal }; const doc = this.styleDocument; // Delegate bulk handling of events happening within the DOM tree of the Rules view // to this.handleEvent(). Listening on the capture phase of the event bubbling to be // able to stop event propagation on a case-by-case basis and prevent event target // ancestor nodes from handling them. this.styleDocument.addEventListener("click", this, { capture: true, signal, }); this.element = doc.getElementById("ruleview-container-focusable"); this.addRuleButton = doc.getElementById("ruleview-add-rule-button"); this.searchField = doc.getElementById("ruleview-searchbox"); this.searchClearButton = doc.getElementById("ruleview-searchinput-clear"); this.pseudoClassPanel = doc.getElementById("pseudo-class-panel"); this.pseudoClassToggle = doc.getElementById("pseudo-class-panel-toggle"); this.pseudoClassesStandardPanel = doc.getElementById( "pseudo-classes-standard" ); this.pseudoClassesElementSpecificPanel = doc.getElementById( "pseudo-classes-element-specific" ); this.pseudoClassesElementSpecificHeading = doc.getElementById( "pseudo-classes-element-specific-heading" ); this.classPanel = doc.getElementById("ruleview-class-panel"); this.classToggle = doc.getElementById("class-panel-toggle"); this.colorSchemeLightSimulationButton = doc.getElementById( "color-scheme-simulation-light-toggle" ); this.colorSchemeDarkSimulationButton = doc.getElementById( "color-scheme-simulation-dark-toggle" ); this.printSimulationButton = doc.getElementById("print-simulation-toggle"); this.#initSimulationFeatures(); this.searchClearButton.hidden = true; this.onHighlighterShown = data => this.handleHighlighterEvent("highlighter-shown", data); this.onHighlighterHidden = data => this.handleHighlighterEvent("highlighter-hidden", data); this.inspector.highlighters.on( "highlighter-shown", this.onHighlighterShown ); this.inspector.highlighters.on( "highlighter-hidden", this.onHighlighterHidden ); this.shortcuts = new KeyShortcuts({ window: this.styleWindow }); this.shortcuts.on("Escape", event => this.#onShortcut("Escape", event)); this.shortcuts.on("Return", event => this.#onShortcut("Return", event)); this.shortcuts.on("Space", event => this.#onShortcut("Space", event)); this.shortcuts.on("CmdOrCtrl+F", event => this.#onShortcut("CmdOrCtrl+F", event) ); this.element.addEventListener("copy", this.#onCopy, baseEventConfig); this.element.addEventListener( "contextmenu", this.#onContextMenu, baseEventConfig ); this.addRuleButton.addEventListener( "click", this.addNewRule, baseEventConfig ); this.searchField.addEventListener( "input", this.#onFilterStyles, baseEventConfig ); this.searchClearButton.addEventListener( "click", this.#onClearSearch, baseEventConfig ); this.pseudoClassToggle.addEventListener( "click", this.#onTogglePseudoClassPanel, baseEventConfig ); this.classToggle.addEventListener( "click", this.#onToggleClassPanel, baseEventConfig ); // The "change" event bubbles up from checkbox inputs nested within the panel container. this.pseudoClassPanel.addEventListener( "change", this.#onTogglePseudoClass, baseEventConfig ); if (flags.testing) { // In tests, we start listening immediately to avoid having to simulate a mousemove. this.highlighters.addToView(this); } else { this.element.addEventListener( "mousemove", () => { this.highlighters.addToView(this); }, { once: true, signal } ); } this.#prefObserver = new PrefObserver("devtools."); this.#prefObserver.on(PREF_UA_STYLES, this.#handleUAStylePrefChange); this.#prefObserver.on( PREF_DEFAULT_COLOR_UNIT, this.#handleDefaultColorUnitPrefChange ); this.#prefObserver.on(PREF_DRAGGABLE, this.#handleDraggablePrefChange); // Initialize value of this.draggablePropertiesEnabled this.#handleDraggablePrefChange(); this.#prefObserver.on( PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER, this.#handleInplaceEditorFocusNextOnEnterPrefChange ); // Initialize value of this.inplaceEditorFocusNextOnEnter this.#handleInplaceEditorFocusNextOnEnterPrefChange(); // Used from tests this.pseudoClassCheckboxes = this.#createCheckboxes( PSEUDO_CLASSES, this.pseudoClassesStandardPanel ); this.elementSpecificPseudoClassCheckboxes = this.#createCheckboxes( Object.keys(ELEMENT_SPECIFIC_PSEUDO_CLASSES), this.pseudoClassesElementSpecificPanel ); this.#showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES); this.cssExplainersEnabled = Services.prefs.getBoolPref( PREF_CSS_EXPLAINERS, false ); // Add the tooltips and highlighters to the view this.tooltips = new TooltipsOverlay(this); // Used from RuleViewTool class this.cssRegisteredPropertiesByTarget = new Map(); this.#elementsWithPendingClicks = new this.styleWindow.WeakSet(); } #abortController; #prefObserver; #elementsWithPendingClicks; #showUserAgentStyles; #focusNextUserAddedRule; // References to all active rule containers DOM Elements. // Containers can be: "pseudo element", "inherited by", "keyframes",... // Map(String => Object { header: DOM Element, container: DOM Element} ) // Map(Container ID => Header and container DOM Elements) #containers = new Map(); // Variable used to stop the propagation of mouse events to children // when we are updating a value by dragging the mouse and we then release it #childHasDragged = false; // The element that we're inspecting. // (Used from RuleViewTool class) selectedNodeFront = null; // Used for cancelling timeouts in the style filter. #filterChangedTimeout = null; // Empty, unconnected element of the same type as this selected node, // used to figure out how shorthand properties will be parsed. #dummyElement = null; #popup; get popup() { if (!this.#popup) { // The popup will be attached to the toolbox document. this.#popup = new AutocompletePopup(this.inspector.toolbox.doc, { autoSelect: true, }); } return this.#popup; } #classListPreviewer; get classListPreviewer() { if (!this.#classListPreviewer) { this.#classListPreviewer = new ClassListPreviewer( this.inspector, this.classPanel ); } return this.#classListPreviewer; } #contextMenu; get contextMenu() { if (!this.#contextMenu) { this.#contextMenu = new StyleInspectorMenu(this, { isRuleView: true }); } return this.#contextMenu; } // Get the dummy element. get dummyElement() { return this.#dummyElement; } #refreshDummyElement() { // Only update the dummy element if the selected element's tag is different. if ( !this.selectedNodeFront || this.#dummyElement?.tagName === this.selectedNodeFront.tagName ) { return; } // To figure out how shorthand properties are interpreted by the // engine, we will set properties on a dummy element and observe // how their .style attribute reflects them as computed values. try { // ::before and ::after do not have a namespaceURI const namespaceURI = this.selectedNodeFront.namespaceURI || this.styleDocument.documentElement.namespaceURI; this.#dummyElement = this.styleDocument.createElementNS( namespaceURI, this.selectedNodeFront.tagName ); } catch (e) { console.error("Error while creating dummy element", e); } } // Get the highlighters overlay from the Inspector. #highlighters; get highlighters() { if (!this.#highlighters) { // highlighters is a lazy getter in the inspector. this.#highlighters = this.inspector.highlighters; } return this.#highlighters; } // Get the filter search value. get searchValue() { return this.searchField.value.toLowerCase(); } get rules() { return this.elementStyle ? this.elementStyle.rules : []; } get currentTarget() { return this.inspector.toolbox.target; } /** * Highlight/unhighlight all the nodes that match a given rule's selector * inside the document of the current selected node. * Only one selector can be highlighted at a time, so calling the method a * second time with a different rule will first unhighlight the previously * highlighted nodes. * Calling the method a second time with the same rule will just * unhighlight the highlighted nodes. * * @param {Rule} rule * @param {string} selector * Elements matching this selector will be highlighted on the page. * @param {boolean} highlightFromRulesSelector */ async toggleSelectorHighlighter( rule, selector, highlightFromRulesSelector = true ) { if (this.isSelectorHighlighted(selector)) { await this.inspector.highlighters.hideHighlighterType( this.inspector.highlighters.TYPES.SELECTOR ); } else { const options = { hideInfoBar: true, hideGuides: true, // we still pass the selector (which can be the StyleRuleFront#computedSelector) // even if highlightFromRulesSelector is set to true, as it's how we keep track // of which selector is highlighted. selector, }; if (highlightFromRulesSelector) { options.ruleActorID = rule.domRule.actorID; } await this.inspector.highlighters.showHighlighterTypeForNode( this.inspector.highlighters.TYPES.SELECTOR, this.inspector.selection.nodeFront, options ); } } isPanelVisible() { return ( this.inspector.toolbox && this.inspector.sidebar && this.inspector.toolbox.currentToolId === "inspector" && (this.inspector.sidebar.getCurrentTabID() == "ruleview" || this.inspector.isThreePaneModeEnabled) ); } /** * Check whether a SelectorHighlighter is active for the given selector text. * * @param {string} selector * @return {boolean} */ isSelectorHighlighted(selector) { const options = this.inspector.highlighters.getOptionsForActiveHighlighter( this.inspector.highlighters.TYPES.SELECTOR ); return options?.selector === selector; } /** * Delegate handler for events happening within the DOM tree of the Rules view. * Itself delegates to specific handlers by event type. * * Use this instead of attaching specific event handlers when: * - there are many elements with the same event handler (eases memory pressure) * - you want to avoid having to remove event handlers manually * - elements are added/removed from the DOM tree arbitrarily over time * * @param {MouseEvent|UIEvent} event */ handleEvent(event) { if (this.#childHasDragged) { this.#childHasDragged = false; event.stopPropagation(); return; } switch (event.type) { case "click": this.handleClickEvent(event); break; default: } } /** * Delegate handler for click events happening within the DOM tree of the Rules view. * Stop propagation of click event wrapping a CSS rule or CSS declaration to avoid * triggering the prompt to add a new CSS declaration or to edit the existing one. * * @param {MouseEvent} event */ async handleClickEvent(event) { const target = event.target; // Handle click on the icon next to a CSS selector. if (target.classList.contains("js-toggle-selector-highlighter")) { event.stopPropagation(); let selector = target.dataset.computedSelector; const highlightFromRulesSelector = !!selector && !target.dataset.isUniqueSelector; // dataset.computedSelector will be initially empty for inline styles (inherited or not) // Rules associated with a regular selector should have this data-attribute // set in devtools/client/inspector/rules/views/rule-editor.js const rule = getRuleFromNode(target, this.elementStyle); if (selector === "") { try { if (rule.inherited) { // This is an inline style from an inherited rule. Need to resolve the // unique selector from the node which this rule is inherited from. selector = await rule.inherited.getUniqueSelector(); } else { // This is an inline style from the current node. selector = await this.inspector.selection.nodeFront.getUniqueSelector(); } // Now that the selector was computed, we can store it for subsequent usage. target.dataset.computedSelector = selector; target.dataset.isUniqueSelector = true; } finally { // Could not resolve a unique selector for the inline style. } } this.toggleSelectorHighlighter( rule, selector, highlightFromRulesSelector ); } // Handle click on swatches next to flex and inline-flex CSS properties if (target.classList.contains("js-toggle-flexbox-highlighter")) { event.stopPropagation(); this.inspector.highlighters.toggleFlexboxHighlighter( this.inspector.selection.nodeFront, "rule" ); } // Handle click on swatches next to grid CSS properties if (target.classList.contains("js-toggle-grid-highlighter")) { event.stopPropagation(); this.inspector.highlighters.toggleGridHighlighter( this.inspector.selection.nodeFront, "rule" ); } const valueSpan = target.closest(".ruleview-propertyvalue"); if (valueSpan) { if (this.#elementsWithPendingClicks.has(valueSpan)) { // When we start handling a drag in the TextPropertyEditor valueSpan, // we make the valueSpan capture the pointer. Then, `click` event target is always // the valueSpan with the latest spec of Pointer Events. // Therefore, we should stop immediate propagation of the `click` event // if we've handled a drag to prevent moving focus to the inplace editor. event.stopImmediatePropagation(); return; } // Handle link click in RuleEditor property value if (target.nodeName === "a") { event.stopPropagation(); event.preventDefault(); openContentLink(target.href, { relatedToCurrent: true, inBackground: event.button === 1 || (lazy.AppConstants.platform === "macosx" ? event.metaKey : event.ctrlKey), }); } } } /** * Delegate handler for highlighter events. * * This is the place to observe for highlighter events, check the highlighter type and * event name, then react to specific events, for example by modifying the DOM. * * @param {string} eventName * Highlighter event name. One of: "highlighter-hidden", "highlighter-shown" * @param {object} data * Object with data associated with the highlighter event. */ handleHighlighterEvent(eventName, data) { switch (data.type) { // Toggle the "highlighted" class on selector icons in the Rules view when // the SelectorHighlighter is shown/hidden for a certain CSS selector. case this.inspector.highlighters.TYPES.SELECTOR: { const selector = data?.options?.selector; if (!selector) { return; } const query = `.js-toggle-selector-highlighter[data-computed-selector='${selector}']`; for (const node of this.styleDocument.querySelectorAll(query)) { const isHighlighterDisplayed = eventName == "highlighter-shown"; node.classList.toggle("highlighted", isHighlighterDisplayed); node.setAttribute("aria-pressed", isHighlighterDisplayed); } } break; // Toggle the "aria-pressed" attribute on swatches next to flex and inline-flex CSS properties // when the FlexboxHighlighter is shown/hidden for the currently selected node. case this.inspector.highlighters.TYPES.FLEXBOX: { const query = ".js-toggle-flexbox-highlighter"; for (const node of this.styleDocument.querySelectorAll(query)) { node.setAttribute("aria-pressed", eventName == "highlighter-shown"); } } break; // Toggle the "aria-pressed" class on swatches next to grid CSS properties // when the GridHighlighter is shown/hidden for the currently selected node. case this.inspector.highlighters.TYPES.GRID: { const query = ".js-toggle-grid-highlighter"; for (const node of this.styleDocument.querySelectorAll(query)) { // From the Layout panel, we can toggle grid highlighters for nodes which are // not currently selected. The Rules view shows `display: grid` declarations // only for the selected node. Avoid mistakenly marking them as "active". if (data.nodeFront === this.inspector.selection.nodeFront) { node.setAttribute( "aria-pressed", eventName == "highlighter-shown" ); } // When the max limit of grid highlighters is reached (default 3), // mark inactive grid swatches as disabled. node.toggleAttribute( "disabled", !this.inspector.highlighters.canGridHighlighterToggle( this.inspector.selection.nodeFront ) ); } } break; } } /** * Enables the print and color scheme simulation only for local and remote tab debugging. */ async #initSimulationFeatures() { if (!this.inspector.commands.descriptorFront.isTabDescriptor) { return; } this.colorSchemeLightSimulationButton.removeAttribute("hidden"); this.colorSchemeDarkSimulationButton.removeAttribute("hidden"); this.printSimulationButton.removeAttribute("hidden"); const { signal } = this.#abortController; const baseEventConfig = { signal }; this.printSimulationButton.addEventListener( "click", this.#onTogglePrintSimulation, baseEventConfig ); this.colorSchemeLightSimulationButton.addEventListener( "click", this.#onToggleLightColorSchemeSimulation, baseEventConfig ); this.colorSchemeDarkSimulationButton.addEventListener( "click", this.#onToggleDarkColorSchemeSimulation, baseEventConfig ); const { rfpCSSColorScheme } = this.inspector.walker; if (rfpCSSColorScheme) { this.colorSchemeLightSimulationButton.setAttribute("disabled", true); this.colorSchemeDarkSimulationButton.setAttribute("disabled", true); console.warn("Color scheme simulation is disabled in RFP mode."); } } /** * Get the type of a given node in the rule-view * * @param {DOMNode} node * The node which we want information about * @return {object | null} containing the following props: * - type {String} One of the VIEW_NODE_XXX_TYPE const in * client/inspector/shared/node-types. * - rule {Rule} The Rule object. * - value {Object} Depends on the type of the node. * Otherwise, returns null if the node isn't anything we care about. */ getNodeInfo(node) { return getNodeInfo(node, this.elementStyle); } /** * Get the node's compatibility issues * * @param {DOMNode} node * The node which we want information about * @return {object | null} containing the following props: * - type {String} Compatibility issue type. * - property {string} The incompatible rule * - alias {Array} The browser specific alias of rule * - url {string} Link to MDN documentation * - deprecated {bool} True if the rule is deprecated * - experimental {bool} True if rule is experimental * - unsupportedBrowsers {Array} Array of unsupported browser * Otherwise, returns null if the node has cross-browser compatible CSS */ async getNodeCompatibilityInfo(node) { const compatibilityInfo = await getNodeCompatibilityInfo( node, this.elementStyle ); return compatibilityInfo; } /** * Context menu handler. */ #onContextMenu = event => { const inInput = event.composedTarget.matches( "input:is([type=text], [type=search], :not([type])), textarea" ); if (inInput) { return; } event.stopPropagation(); event.preventDefault(); this.contextMenu.show(event); }; /** * Callback for copy event. Copy the selected text. * * @param {Event} event * copy event object. */ #onCopy = event => { if (event) { this.copySelection(event.target); event.preventDefault(); event.stopPropagation(); } }; /** * Copy the current selection. The current target is necessary * if the selection is inside an input or a textarea * * @param {DOMNode} target * DOMNode target of the copy action */ copySelection(target) { try { let text = ""; const nodeName = target?.nodeName; const targetType = target?.type; if ( // The target can be the enable/disable rule checkbox here (See Bug 1680893). (nodeName === "input" && targetType !== "checkbox") || nodeName == "textarea" ) { const start = Math.min(target.selectionStart, target.selectionEnd); const end = Math.max(target.selectionStart, target.selectionEnd); const count = end - start; text = target.value.substr(start, count); } else { text = this.styleWindow.getSelection().toString(); // Remove any double newlines. text = text.replace(/(\r?\n)\r?\n/g, "$1"); } clipboardHelper.copyString(text); } catch (e) { console.error(e); } } /** * Add a new rule to the current element. */ addNewRule() { // Clear the search input so the new rule is visible this.#onClearSearch({ focusSearchField: false }); this.#focusNextUserAddedRule = true; this.pageStyle.addNewRule( this.selectedNodeFront, this.selectedNodeFront.pseudoClassLocks ); } /** * Returns true if the "Add Rule" action (either via the addRuleButton or the context * menu entry) can be performed for the currently selected node. * * @returns {boolean} */ canAddNewRuleForSelectedNode() { return this.selectedNodeFront && this.inspector.selection.isElementNode(); } /** * Return {Boolean} true if the rule view currently has an input * editor visible. */ get isEditing() { return ( this.tooltips.isEditing || !!this.element.querySelectorAll(".styleinspector-propertyeditor").length ); } #handleUAStylePrefChange = () => { this.#showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES); this.#handlePrefChange(PREF_UA_STYLES); }; #handleDefaultColorUnitPrefChange = () => { this.#handlePrefChange(PREF_DEFAULT_COLOR_UNIT); }; #handleDraggablePrefChange = () => { this.draggablePropertiesEnabled = Services.prefs.getBoolPref( PREF_DRAGGABLE, false ); // This event is consumed by text-property-editor instances in order to // update their draggable behavior. Preferences observer are costly, so // we are forwarding the preference update via the EventEmitter. this.emit("draggable-preference-updated"); }; #handleInplaceEditorFocusNextOnEnterPrefChange = () => { this.inplaceEditorFocusNextOnEnter = Services.prefs.getBoolPref( PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER, false ); this.#handlePrefChange(PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER); }; #handlePrefChange(pref) { // Reselect the currently selected element const refreshOnPrefs = [ PREF_UA_STYLES, PREF_DEFAULT_COLOR_UNIT, PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER, ]; if (this.selectedNodeFront && refreshOnPrefs.includes(pref)) { this.selectElement(this.selectedNodeFront, true); } } /** * Set the filter style search value. * * @param {string} value * The search value. * @param {object} options * @param {boolean} options.focusSearchField * Whether or not to focus the search input. Defaults to true. */ setFilterStyles(value = "", { focusSearchField = true } = {}) { this.searchField.value = value; if (focusSearchField) { this.searchField.focus(); } this.#onFilterStyles(); } /** * Called when the user enters a search term in the filter style search box. * The actual filtering (done in #doFilterStyles) will be throttled if the search input * isn't empty, but will happen immediately when the search gets cleared. */ #onFilterStyles = () => { if (this.#filterChangedTimeout) { clearTimeout(this.#filterChangedTimeout); } const isSearchEmpty = this.searchValue.length === 0; this.searchClearButton.hidden = isSearchEmpty; // If the search is cleared update the UI directly so calls to this function (or any // callsite of it) can assume the UI is up to date directly after the call. if (isSearchEmpty) { this.#doFilterStyles(); } else { this.#filterChangedTimeout = setTimeout( () => this.#doFilterStyles(), FILTER_CHANGED_TIMEOUT ); } }; /** * Actually update search data and update the UI to reflect the current search. * * @fires ruleview-filtered */ #doFilterStyles() { this.searchData = { searchPropertyMatch: FILTER_PROP_RE.exec(this.searchValue), searchPropertyName: this.searchValue, searchPropertyValue: this.searchValue, strictSearchValue: "", strictSearchPropertyName: false, strictSearchPropertyValue: false, strictSearchAllValues: false, }; if (this.searchData.searchPropertyMatch) { // Parse search value as a single property line and extract the // property name and value. If the parsed property name or value is // contained in backquotes (`), extract the value within the backquotes // and set the corresponding strict search for the property to true. if (FILTER_STRICT_RE.test(this.searchData.searchPropertyMatch[1])) { this.searchData.strictSearchPropertyName = true; this.searchData.searchPropertyName = FILTER_STRICT_RE.exec( this.searchData.searchPropertyMatch[1] )[1]; } else { this.searchData.searchPropertyName = this.searchData.searchPropertyMatch[1]; } if (FILTER_STRICT_RE.test(this.searchData.searchPropertyMatch[2])) { this.searchData.strictSearchPropertyValue = true; this.searchData.searchPropertyValue = FILTER_STRICT_RE.exec( this.searchData.searchPropertyMatch[2] )[1]; } else { this.searchData.searchPropertyValue = this.searchData.searchPropertyMatch[2]; } // Strict search for stylesheets will match the property line regex. // Extract the search value within the backquotes to be used // in the strict search for stylesheets in #highlightStyleSheet. if (FILTER_STRICT_RE.test(this.searchValue)) { this.searchData.strictSearchValue = FILTER_STRICT_RE.exec( this.searchValue )[1]; } } else if (FILTER_STRICT_RE.test(this.searchValue)) { // If the search value does not correspond to a property line and // is contained in backquotes, extract the search value within the // backquotes and set the flag to perform a strict search for all // the values (selector, stylesheet, property and computed values). const searchValue = FILTER_STRICT_RE.exec(this.searchValue)[1]; this.searchData.strictSearchAllValues = true; this.searchData.searchPropertyName = searchValue; this.searchData.searchPropertyValue = searchValue; this.searchData.strictSearchValue = searchValue; } this.#clearHighlight(this.element); this.#clearRules(); this.#createEditors(); this.inspector.emit("ruleview-filtered"); } /** * Called when the user clicks on the clear button in the filter style search * box. Returns true if the search box is cleared and false otherwise. * * @param {object} options * Options that will be passed to `setFilterStyles` */ #onClearSearch = options => { if (this.searchField.value) { this.setFilterStyles("", options); return true; } return false; }; destroy() { this.isDestroyed = true; this.selectedNodeFront = null; if (this.elementStyle) { this.elementStyle.destroy(); this.elementStyle = null; } if (this.pageStyle) { this.pageStyle.off("stylesheet-updated", this.refreshPanel); this.pageStyle = null; } this.#dummyElement = null; this.#prefObserver.destroy(); this.outputParser = null; if (this.#classListPreviewer) { this.#classListPreviewer.destroy(); this.#classListPreviewer = null; } if (this.#contextMenu) { this.#contextMenu.destroy(); this.#contextMenu = null; } if (this.#highlighters) { this.#highlighters.removeFromView(this); this.#highlighters = null; } this.colorSchemeLightSimulationButton = null; this.colorSchemeDarkSimulationButton = null; this.printSimulationButton = null; this.tooltips.destroy(); this.#abortController.abort(); this.#abortController = null; this.#containers.clear(); this.shortcuts.destroy(); this.inspector.highlighters.off( "highlighter-shown", this.onHighlighterShown ); this.inspector.highlighters.off( "highlighter-hidden", this.onHighlighterHidden ); this.searchField = null; this.searchClearButton = null; this.pseudoClassPanel = null; this.pseudoClassToggle = null; this.pseudoClassCheckboxes = null; this.pseudoClassesStandardPanel = null; this.pseudoClassesElementSpecificPanel = null; this.pseudoClassesElementSpecificHeading = null; this.elementSpecificPseudoClassCheckboxes = null; this.classPanel = null; this.classToggle = null; this.inspector = null; this.styleDocument = null; this.styleWindow = null; if (this.element.parentNode) { this.element.remove(); } if (this.#popup) { this.#popup.destroy(); this.#popup = null; } } /** * Mark the view as selecting an element, disabling all interaction, and * visually clearing the view after a few milliseconds to avoid confusion * about which element's styles the rule view shows. */ #startSelectingElement() { this.element.classList.add("non-interactive"); } /** * Mark the view as no longer selecting an element, re-enabling interaction. */ #stopSelectingElement() { this.element.classList.remove("non-interactive"); } /** * Disables add rule button when needed */ #refreshAddRuleButtonState() { this.addRuleButton.disabled = !this.canAddNewRuleForSelectedNode(); } /** * Update pageStyle reference and listen for stylesheet updates. */ #refreshPageStyle() { const newPageStyle = this.selectedNodeFront?.inspectorFront.pageStyle; if (this.pageStyle == newPageStyle) { return; } // If we were already selecting an element from a different process, // we should unregister the PageStyle Actor event listener if (this.pageStyle) { this.pageStyle.off("stylesheet-updated", this.refreshPanel); this.pageStyle = null; } // If we are selecting a new element, we should also start listening // for event from its process and its related Page Style Actor. if (newPageStyle) { this.pageStyle = newPageStyle; this.pageStyle.on("stylesheet-updated", this.refreshPanel); } } /** * Update the view with a new selected element. * * @param {NodeActor} element * The node whose style rules we'll inspect. * @param {boolean} forceRefresh * Update the view even if the element is the same as last time. */ async selectElement(element, forceRefresh = false) { const sameElementSelected = this.selectedNodeFront === element; if (sameElementSelected && !forceRefresh) { return; } // 1/3 All cleanups that do not depend on former/new selected element if (this.#popup && this.#popup.isOpen) { this.#popup.hidePopup(); } // 2/3 Important step: actually switch to a new or empty element this.selectedNodeFront = element; // 3/3 Now update based on the newly selected element // Wipe the whole rule view content when deselecting or selecting another element // as there is little chance we would display the same rules. It is faster // to render everything from scratch than trying to do incremental updates. if (!sameElementSelected) { this.#clearRules(); } this.#refreshEmptyNotice(); this.#refreshAddRuleButtonState(); this.#refreshDummyElement(); this.#refreshPageStyle(); this.#refreshPseudoClassPanel(); if (!element) { this.#stopSelectingElement(); // Destroy the ElementStyle *after* having cleared the rule view // (earlier call to `#clearRules`), as it may destroy each DOM element // for each rule individually and cause unecessary reflows if (this.elementStyle) { this.elementStyle.destroy(); this.elementStyle = null; } return; } const isProfilerActive = Services.profiler.IsActive(); const startTime = isProfilerActive ? ChromeUtils.now() : null; const done = this.inspector.updating("rule-view"); const elementStyle = new ElementStyle( element, this, this.store, this.pageStyle, this.#showUserAgentStyles ); let previousElementStyle = this.elementStyle; this.elementStyle = elementStyle; this.#startSelectingElement(); try { // Bug 2016127: This is historical, but unfortunately breaks some tests if removed await Promise.resolve(null); await this.#populate(); if (this.elementStyle !== elementStyle) { done(); return; } this.elementStyle.onChanged = () => { this.#onElementStyleChanged(); }; // Cleanup the previous ElementStyle model only after the refresh // as it will destroy each rule individually and may cause uncessary reflows // if entire containers are removed. if (previousElementStyle) { previousElementStyle.destroy(); previousElementStyle = null; // We need to update containers as some may now be empty this.#updateContainers(); } this.#stopSelectingElement(); if (isProfilerActive && this.elementStyle.rules) { let declarations = 0; for (const rule of this.elementStyle.rules) { declarations += rule.textProps.length; } ChromeUtils.addProfilerMarker( "DevTools:CssRuleView.selectElement", startTime, `${declarations} CSS declarations in ${this.elementStyle.rules.length} rules` ); } } catch (e) { if (this.elementStyle === elementStyle) { this.#stopSelectingElement(); this.#clearRules(); } console.error("Error while updating the rule view", e); } done(); } /** * Update the rules for the currently highlighted element. */ async refreshPanel() { // Ignore refreshes when the panel is hidden, or during editing or when no element is selected. if (!this.isPanelVisible() || this.isEditing || !this.elementStyle) { return; } // Repopulate the element style once the current modifications are done. const promises = []; for (const rule of this.elementStyle.rules) { if (rule.applyingModifications) { promises.push(rule.applyingModifications); } } await Promise.all(promises); await this.#populate(); } /** * For each pseudo-class item, create a checkbox input element for toggling a * pseudo-class on the selected element and append it to passed container. * * @param {Array} items * An array of pseudo-class items to create checkboxes for. * @param {HTMLElement} container * The container element to which the checkboxes will be appended. * * @returns {Array} An array with the checkbox input elements for pseudo-classes. */ #createCheckboxes(items, container) { const doc = this.styleDocument; const fragment = doc.createDocumentFragment(); const checkboxes = []; for (const item of items) { const label = doc.createElement("label"); const checkbox = doc.createElement("input"); checkbox.setAttribute("type", "checkbox"); checkbox.setAttribute("value", item); label.append(checkbox, item); fragment.append(label); checkboxes.push(checkbox); } container.append(fragment); return checkboxes; } /** * Get the element-specific pseudo-classes that apply to the currently selected element. * * @returns {Array} Array of pseudo-classes that apply to the current element */ #getApplicableElementSpecificPseudoClasses() { if (!this.selectedNodeFront) { return []; } const tagName = this.selectedNodeFront.tagName?.toLowerCase(); const applicablePseudoClasses = []; for (const [pseudo, elementTypes] of Object.entries( ELEMENT_SPECIFIC_PSEUDO_CLASSES )) { if (elementTypes.has(tagName)) { applicablePseudoClasses.push(pseudo); } } return applicablePseudoClasses; } /** * Update the pseudo class options for the currently highlighted element. */ #refreshPseudoClassPanel() { if ( !this.selectedNodeFront || !this.inspector.canTogglePseudoClassForSelectedNode() ) { for (const checkbox of [ ...this.pseudoClassCheckboxes, ...this.elementSpecificPseudoClassCheckboxes, ]) { checkbox.disabled = true; } this.#updateElementSpecificPseudoClassPanel(); return; } const pseudoClassLocks = this.selectedNodeFront.pseudoClassLocks; for (const checkbox of this.pseudoClassCheckboxes) { checkbox.disabled = false; checkbox.checked = pseudoClassLocks.includes(checkbox.value); } const applicablePseudoClasses = this.#getApplicableElementSpecificPseudoClasses(); for (const checkbox of this.elementSpecificPseudoClassCheckboxes) { const isApplicable = applicablePseudoClasses.includes(checkbox.value); checkbox.disabled = !isApplicable; if (isApplicable) { checkbox.checked = pseudoClassLocks.includes(checkbox.value); } } this.#updateElementSpecificPseudoClassPanel(); } /** * Update the visibility of the element-specific pseudo-class panel. * Show the panel if there are applicable pseudo-classes, otherwise hide it. */ #updateElementSpecificPseudoClassPanel() { const applicablePseudoClasses = this.#getApplicableElementSpecificPseudoClasses(); const hasApplicablePseudoClasses = !!applicablePseudoClasses.length; this.pseudoClassesElementSpecificHeading.hidden = !hasApplicablePseudoClasses; } async #populate() { try { let focusedElSelector; // `createEditors()` is going to create/modify/update/move DOM Elements // whereas we would like to preserve the focus state. // // Compute the selector early, before some other parallel work can blur the selected // declaration DOM element while waiting for async `ElementStyle.populate()` method. let focusedElement; if (this.element.contains(this.styleDocument.activeElement)) { focusedElement = this.styleDocument.activeElement; focusedElSelector = CssLogic.findCssSelector(focusedElement); } const elementStyle = this.elementStyle; await this.elementStyle.populate(); if (this.elementStyle !== elementStyle || this.isDestroyed) { return; } // If, by the time we start updating the DOM, the previously focused element // has been removed from the DOM, stop trying to restore focus on it. // // `ElementStyle.populate()` will remove from the DOM the removed CSS rules. if ( focusedElement && !focusedElement?.isConnected && !this.styleDocument.querySelector(focusedElSelector) ) { focusedElSelector = ""; } await this.#createEditors(); // Set focus if the focus is still in the current document (avoid stealing // the focus, see Bug 1911627). if (focusedElSelector && this.styleDocument.hasFocus()) { const elementToFocus = this.styleDocument.querySelector(focusedElSelector); if (elementToFocus && this.element.contains(elementToFocus)) { elementToFocus.focus(); } } // Notify anyone that cares that we refreshed. this.inspector.emit("rule-view-refreshed"); } catch (e) { console.error("Exception while populating the rule view", e); throw e; } } /** * Show or hide the empty notice depending on selected node. */ #refreshEmptyNotice() { const emptyNotice = this.styleDocument.getElementById( "ruleview-no-results" ); if (this.selectedNodeFront && emptyNotice) { emptyNotice.remove(); } else if (!this.selectedNodeFront && !emptyNotice) { createChild(this.element, "div", { id: "ruleview-no-results", class: "devtools-sidepanel-no-result", textContent: l10n("rule.empty"), }); } } /** * Clear the rules. */ #clearRules() { this.#containers.clear(); this.element.innerHTML = ""; } /** * Called when the user has made changes to the ElementStyle. * Emits an event that clients can listen to. */ #onElementStyleChanged() { this.emit("ruleview-changed"); } /** * Text for header that shows above rules for this element */ #selectedElementLabel; get selectedElementLabel() { if (this.#selectedElementLabel) { return this.#selectedElementLabel; } this.#selectedElementLabel = l10n("rule.selectedElement"); return this.#selectedElementLabel; } /** * Text for header that shows above rules for pseudo elements */ #pseudoElementLabel; get pseudoElementLabel() { if (this.#pseudoElementLabel) { return this.#pseudoElementLabel; } this.#pseudoElementLabel = l10n("rule.pseudoElement"); return this.#pseudoElementLabel; } #showPseudoElements; get showPseudoElements() { if (this.#showPseudoElements === undefined) { this.#showPseudoElements = Services.prefs.getBoolPref( "devtools.inspector.show_pseudo_elements" ); } return this.#showPseudoElements; } /** * Creates a simple, non-expandable container in the rule view * * @param {string} label * The label for the container header * @param {string} containerId * The id that will be set on the container * @return {Object{ header:DOMElement, container: DOMElement }} * Object containing both the container element and its related header. */ createSimpleContainer(label, containerId) { const header = this.styleDocument.createElementNS(HTML_NS, "div"); header.className = RULE_VIEW_HEADER_CLASSNAME; header.setAttribute("role", "heading"); // Element container is only shown when pseudo element container exists // which can only be computed later after having processed all rules. if (containerId == ELEMENT_CONTAINER_ID) { header.hidden = true; } header.append(label); const container = this.styleDocument.createElementNS(HTML_NS, "div"); container.classList.add("ruleview-container"); container.id = containerId; this.#containers.set(containerId, { header, container }); return { header, container }; } /** * Creates an expandable container in the rule view * * @param {string} label * The label for the container header * @param {string} containerId * The id that will be set on the container * @return {Object{ header:DOMElement, container: DOMElement }} * Object containing both the container element and its related header. */ createExpandableContainer(label, containerId) { const header = this.styleDocument.createElementNS(HTML_NS, "div"); header.classList.add( RULE_VIEW_HEADER_CLASSNAME, "ruleview-expandable-header" ); header.setAttribute("role", "heading"); header.setAttribute("aria-level", "3"); header.hidden = false; const toggleButton = this.styleDocument.createElementNS(HTML_NS, "button"); toggleButton.setAttribute( "title", l10n("rule.expandableContainerToggleButton.title") ); toggleButton.setAttribute("aria-expanded", "true"); toggleButton.setAttribute("aria-controls", containerId); const twisty = this.styleDocument.createElementNS(HTML_NS, "span"); twisty.className = "ruleview-expander theme-twisty"; toggleButton.append(twisty, label); header.append(toggleButton); const container = this.styleDocument.createElementNS(HTML_NS, "div"); container.id = containerId; container.classList.add( "ruleview-container", "ruleview-expandable-container" ); container.hidden = false; this.#containers.set(containerId, { header, container }); const isPseudo = containerId == PSEUDO_ELEMENTS_CONTAINER_ID; const { signal } = this.#abortController; toggleButton.addEventListener( "click", this.#toggleContainerVisibility.bind( this, containerId, isPseudo, !this.showPseudoElements ), { signal } ); if (isPseudo) { this.#toggleContainerVisibility( containerId, isPseudo, this.showPseudoElements ); } return { header, container }; } /** * Create the `@property` expandable container * * @returns {Element} */ getOrCreateRegisteredPropertiesExpandableContainer() { const existingEntry = this.#containers.get( REGISTERED_PROPERTIES_CONTAINER_ID ); if (existingEntry) { return existingEntry.container; } const { header, container } = this.createExpandableContainer( "@property", REGISTERED_PROPERTIES_CONTAINER_ID ); this.#containers.set(REGISTERED_PROPERTIES_CONTAINER_ID, { header, container, }); this.element.append(header, container); return container; } /** * Return the RegisteredPropertyEditor element for a given property name * * @param {string} registeredPropertyName * @returns {Element|null} */ getRegisteredPropertyElement(registeredPropertyName) { return this.styleDocument.querySelector( `#${REGISTERED_PROPERTIES_CONTAINER_ID} [data-name="${registeredPropertyName}"]` ); } /** * Toggle the visibility of an expandable container * * @param {string} containerId * Container ID. * @param {boolean} isPseudo * Whether or not the container will hold pseudo element rules * @param {boolean} showPseudo * Whether or not pseudo element rules should be displayed */ #toggleContainerVisibility(containerId, isPseudo, showPseudo) { const { header, container } = this.#containers.get(containerId); const toggleButton = header.querySelector("button"); let isOpen = toggleButton.getAttribute("aria-expanded") === "true"; if (isPseudo) { this.#showPseudoElements = !!showPseudo; Services.prefs.setBoolPref( "devtools.inspector.show_pseudo_elements", this.showPseudoElements ); container.hidden = !this.showPseudoElements; isOpen = !this.showPseudoElements; } else { container.hidden = !container.hidden; } toggleButton.setAttribute("aria-expanded", !isOpen); } /** * Creates editor UI for each of the rules in elementStyle. */ #createEditors() { // Run through the current list of rules, attaching // their editors in order. Create editors if needed. let seenSearchTerm = false; if (!this.elementStyle.rules) { return Promise.resolve(); } // Transient list of containers added to the DOM // while processing this method. const currentContainers = []; const editorReadyPromises = []; const lastElementPerContainer = new Map(); for (const rule of this.elementStyle.rules) { // Initialize rule editor if this is a new rule if (!rule.editor) { editorReadyPromises.push(this.#createEditorForRule(rule)); } // Filter the rules and highlight any matches if there is a search input if (this.searchValue && this.searchData) { if (this.highlightRule(rule)) { seenSearchTerm = true; } else if (rule.domRule.type !== ELEMENT_STYLE) { continue; } } const container = this.#getContainerForRule(rule, currentContainers); // Ensure adding the rule editor at the right location const lastElement = lastElementPerContainer.get(container); // Also avoid any DOM mutation if the rule already exists and wasn't moved if ( lastElement && lastElement.nextElementSibling != rule.editor.element ) { lastElement.insertAdjacentElement("afterend", rule.editor.element); } else if ( !lastElement && container.firstElementChild != rule.editor.element ) { container.insertAdjacentElement("afterbegin", rule.editor.element); } lastElementPerContainer.set(container, rule.editor.element); } this.#createRegisteredPropertyEditors(); this.#updateContainers(); // Automatically select the selector input when we are adding a user-added rule. // (Focus after having updated all the rules to prevent the focus from being // lost because of possible following DOM updates) if (this.#focusNextUserAddedRule) { const rule = this.elementStyle.rules.find(r => r.domRule.userAdded); if (rule) { rule.editor.selectorText.click(); this.emitForTests("new-rule-added", rule); } this.#focusNextUserAddedRule = null; } const searchBox = this.searchField.parentNode; searchBox.classList.toggle( "devtools-searchbox-no-match", this.searchValue && !seenSearchTerm ); return Promise.all(editorReadyPromises); } /** * Instantiate a new RuleEditor for a given rule * currently applying to the selected element. * * @param {Rule} rule */ #createEditorForRule(rule) { const ruleActorID = rule.domRule.actorID; rule.editor = new RuleEditor(this, rule, { elementsWithPendingClicks: this.#elementsWithPendingClicks, onShowUnusedCustomCssProperties: () => { this.store.expandedUnusedCustomCssPropertiesRuleActorIds.add( ruleActorID ); }, shouldHideUnusedCustomCssProperties: !this.store.expandedUnusedCustomCssPropertiesRuleActorIds.has( ruleActorID ), }); return rule.editor.once("source-link-updated"); } /** * Create or get existing container for a given rule. * * @param {Rule} rule * @param {Array} currentContainers */ #getContainerForRule(rule, currentContainers) { let id, label, expandable = false; // Don't display inherited pseudo element rules (e.g. ::details-content) inside // the pseudo element container const isNonInheritedPseudo = !!rule.pseudoElement && !rule.inherited; const keyframes = rule.keyframes; if (isNonInheritedPseudo) { id = PSEUDO_ELEMENTS_CONTAINER_ID; label = this.pseudoElementLabel; expandable = true; } else if (keyframes) { // See bug 1042036 and Bug 1894873 we are showing all keyframes with the same name. // We may use ${keyframes.name}, but all rule's content would be merged // into a unique rule/container. // (only use part of the actorID to have a valid DOM id) id = `keyframes-container-${rule.keyframes.actorID.match(/\w+$/)[0]}`; label = rule.keyframesName; expandable = true; } else if (rule.domRule.className === "CSSPositionTryRule") { id = POSITION_TRY_CONTAINER_ID; label = "@position-try"; expandable = true; } else if (rule.inherited) { // We need to check both `inherited` (a NodeFront) and `pseudoElement` (string), // as element-backed pseudo element rules (e.g. `::details-content`) can have the same // `inherited` property as a regular rule (e.g. on `
`), but the element is // to be considered as a child of the binding element. // // e.g. we want to have: // This element // Inherited by details::details-content // Inherited by details id = `inherited-${rule.inherited.actorID}-${rule.pseudoElement}`; label = rule.inheritedSectionLabel; } else { id = ELEMENT_CONTAINER_ID; label = this.selectedElementLabel; } let entry = this.#containers.get(id); // Create the DOM for the container if it doesn't exist yet if (!entry) { if (expandable) { entry = this.createExpandableContainer(label, id); } else { entry = this.createSimpleContainer(label, id); } } const { header, container } = entry; // Ensure displaying the containers in the new order processed in #createEditors. if (!currentContainers.includes(container)) { const lastContainer = currentContainers.at(-1); // Pseudo element rules are sorted **after** element matching rules and inherited rules // in ElementStyle.rules, but we want the container to always be shown at the top. // // Also avoid any DOM mutation if the rule already exists and wasn't moved if (id == PSEUDO_ELEMENTS_CONTAINER_ID || !lastContainer) { if (this.element.firstElementChild != header) { this.element.insertAdjacentElement("afterbegin", header); header.insertAdjacentElement("afterend", container); } } else if (lastContainer.nextElementSibling != header) { lastContainer.insertAdjacentElement("afterend", header); header.insertAdjacentElement("afterend", container); } currentContainers.push(container); } return container; } /** * Whenever we may add or remove rules in the list, * we have to eventually update containers by removing the empty ones * and update the visibility of "Element" header. */ #updateContainers() { // Clear containers which no longer contain any rule for (const [ containerId, { header, container }, ] of this.#containers.entries()) { if (!container.children.length) { header.remove(); container.remove(); this.#containers.delete(containerId); } } // Only print header for "This element" if there are pseudo elements displayed before const elementEntry = this.#containers.get(ELEMENT_CONTAINER_ID); if (elementEntry) { const hasPseudoElementRules = this.#containers.has( PSEUDO_ELEMENTS_CONTAINER_ID ); // Show the header element, which is before the container element elementEntry.header.hidden = !hasPseudoElementRules; } } #createRegisteredPropertyEditors() { const targetRegisteredProperties = this.getRegisteredPropertiesForSelectedNodeTarget(); if (!targetRegisteredProperties?.size) { // Wipe the list, but only if the properties container was populated const entry = this.#containers.get(REGISTERED_PROPERTIES_CONTAINER_ID); if (entry) { entry.container.replaceChildren(); } return; } const registeredPropertiesContainer = this.getOrCreateRegisteredPropertiesExpandableContainer(); // Always wipe and rebuild the list of properties from scratch registeredPropertiesContainer.replaceChildren(); // Sort properties by their name, as we want to display them in alphabetical order const propertyDefinitions = Array.from( targetRegisteredProperties.values() ).sort((a, b) => (a.name < b.name ? -1 : 1)); for (const propertyDefinition of propertyDefinitions) { const registeredPropertyEditor = new RegisteredPropertyEditor( this, propertyDefinition ); registeredPropertiesContainer.appendChild( registeredPropertyEditor.element ); } } /** * Highlight rules that matches the filter search value and returns a * boolean indicating whether or not rules were highlighted. * * @param {Rule} rule * The rule object we're highlighting if its rule selectors or * property values match the search value. * @return {boolean} true if the rule was highlighted, false otherwise. */ highlightRule(rule) { const isRuleSelectorHighlighted = this.#highlightRuleSelector(rule); const isStyleSheetHighlighted = this.#highlightStyleSheet(rule); const isAncestorRulesHighlighted = this.#highlightAncestorRules(rule); let isHighlighted = isRuleSelectorHighlighted || isStyleSheetHighlighted || isAncestorRulesHighlighted; // Highlight search matches in the rule properties for (const textProp of rule.textProps) { if (!textProp.invisible && this.#highlightProperty(textProp)) { isHighlighted = true; } } return isHighlighted; } /** * Highlights the rule selector that matches the filter search value and * returns a boolean indicating whether or not the selector was highlighted. * * @param {Rule} rule * The Rule object. * @return {boolean} true if the rule selector was highlighted, * false otherwise. */ #highlightRuleSelector(rule) { let isSelectorHighlighted = false; let selectorNodes = [...rule.editor.selectorText.childNodes]; if ( rule.domRule.type === CSSRule.KEYFRAME_RULE || rule.domRule.className === "CSSPositionTryRule" ) { selectorNodes = [rule.editor.selectorText]; } else if (rule.domRule.type === ELEMENT_STYLE) { selectorNodes = []; } // Highlight search matches in the rule selectors for (const selectorNode of selectorNodes) { const selector = selectorNode.textContent.toLowerCase(); if ( (this.searchData.strictSearchAllValues && selector === this.searchData.strictSearchValue) || (!this.searchData.strictSearchAllValues && selector.includes(this.searchValue)) ) { selectorNode.classList.add("ruleview-highlight"); isSelectorHighlighted = true; } } return isSelectorHighlighted; } /** * Highlights the ancestor rules data (@media / @layer) that matches the filter search * value and returns a boolean indicating whether or not element was highlighted. * * @return {boolean} true if the element was highlighted, false otherwise. */ #highlightAncestorRules(rule) { const element = rule.editor.ancestorDataEl; if (!element) { return false; } const ancestorSelectors = element.querySelectorAll( ".ruleview-rule-ancestor-selectorcontainer" ); let isHighlighted = false; for (const child of ancestorSelectors) { const dataText = child.innerText.toLowerCase(); const matches = this.searchData.strictSearchValue ? dataText === this.searchData.strictSearchValue : dataText.includes(this.searchValue); if (matches) { isHighlighted = true; child.classList.add("ruleview-highlight"); } } return isHighlighted; } /** * Highlights the stylesheet source that matches the filter search value and * returns a boolean indicating whether or not the stylesheet source was * highlighted. * * @return {boolean} true if the stylesheet source was highlighted, false * otherwise. */ #highlightStyleSheet(rule) { const styleSheetSource = rule.title.toLowerCase(); const isStyleSheetHighlighted = this.searchData.strictSearchValue ? styleSheetSource === this.searchData.strictSearchValue : styleSheetSource.includes(this.searchValue); if (isStyleSheetHighlighted && rule.editor.source) { rule.editor.source.classList.add("ruleview-highlight"); } return isStyleSheetHighlighted; } /** * Highlights the rule properties and computed properties that match the * filter search value and returns a boolean indicating whether or not the * property or computed property was highlighted. * * @param {TextProperty} textProperty * The rule property. * @returns {boolean} true if the property or computed property was * highlighted, false otherwise. */ #highlightProperty(textProperty) { const isPropertyHighlighted = this.#highlightRuleProperty(textProperty); const isComputedHighlighted = this.#highlightComputedProperty(textProperty); // Expand the computed list if a computed property is highlighted and the // property rule is not highlighted if ( !isPropertyHighlighted && isComputedHighlighted && !textProperty.editor.computed.hasAttribute("user-open") ) { textProperty.editor.expandForFilter(); } return isPropertyHighlighted || isComputedHighlighted; } /** * Called when TextPropertyEditor is updated and updates the rule property * highlight. * * @param {TextPropertyEditor} editor * The rule property TextPropertyEditor object. */ updatePropertyHighlight(editor) { if (!this.searchValue || !this.searchData) { return; } this.#clearHighlight(editor.element); if (this.#highlightProperty(editor.prop)) { this.searchField.classList.remove("devtools-style-searchbox-no-match"); } } /** * Highlights the rule property that matches the filter search value * and returns a boolean indicating whether or not the property was * highlighted. * * @param {TextProperty} textProperty * The rule property object. * @returns {boolean} true if the rule property was highlighted, * false otherwise. */ #highlightRuleProperty(textProperty) { const propertyName = textProperty.name.toLowerCase(); // Get the actual property value displayed in the rule view if we have an editor for // it (that might not be the case for unused CSS custom properties). const propertyValue = textProperty.editor ? textProperty.editor.valueSpan.textContent.toLowerCase() : textProperty.value.toLowerCase(); return this.#highlightMatches({ element: textProperty.editor?.container, propertyName, propertyValue, textProperty, }); } /** * Highlights the computed property that matches the filter search value and * returns a boolean indicating whether or not the computed property was * highlighted. * * @param {TextProperty} textProperty * The rule property object. * @returns {boolean} true if the computed property was highlighted, false * otherwise. */ #highlightComputedProperty(textProperty) { if (!textProperty.editor) { return false; } let isComputedHighlighted = false; // Highlight search matches in the computed list of properties textProperty.editor.populateComputed(); for (const computed of textProperty.computed) { if (computed.element) { // Get the actual property value displayed in the computed list const computedName = computed.name.toLowerCase(); const computedValue = computed.parsedValue.toLowerCase(); isComputedHighlighted = this.#highlightMatches({ element: computed.element, propertyName: computedName, propertyValue: computedValue, textProperty, }) ? true : isComputedHighlighted; } } return isComputedHighlighted; } /** * Helper function for highlightRules that carries out highlighting the given * element if the search terms match the property, and returns a boolean * indicating whether or not the search terms match. * * @param {object} options * @param {DOMNode} options.element * The node to highlight if search terms match * @param {string} options.propertyName * The property name of a rule * @param {string} options.propertyValue * The property value of a rule * @param {TextProperty} options.textProperty * The text property that we may highlight. It's helpful in cases we don't have * an element yet (e.g. if the property is a hidden unused variable) * @return {boolean} true if the given search terms match the property, false * otherwise. */ #highlightMatches({ element, propertyName, propertyValue, textProperty }) { const { searchPropertyName, searchPropertyValue, searchPropertyMatch, strictSearchPropertyName, strictSearchPropertyValue, strictSearchAllValues, } = this.searchData; let matches = false; // If the inputted search value matches a property line like // `font-family: arial`, then check to make sure the name and value match. // Otherwise, just compare the inputted search string directly against the // name and value of the rule property. const hasNameAndValue = searchPropertyMatch && searchPropertyName && searchPropertyValue; const isMatch = (value, query, isStrict) => { return isStrict ? value === query : query && value.includes(query); }; if (hasNameAndValue) { matches = isMatch(propertyName, searchPropertyName, strictSearchPropertyName) && isMatch(propertyValue, searchPropertyValue, strictSearchPropertyValue); } else { matches = isMatch( propertyName, searchPropertyName, strictSearchPropertyName || strictSearchAllValues ) || isMatch( propertyValue, searchPropertyValue, strictSearchPropertyValue || strictSearchAllValues ); } if (!matches) { return false; } // We might not have an element when the prop is an unused custom css property. if (!element && textProperty?.isUnusedVariable) { const editor = textProperty.rule.editor.showUnusedCssVariable(textProperty); // The editor couldn't be created, bail (shouldn't happen) if (!editor) { return false; } element = editor.container; } element.classList.add("ruleview-highlight"); return true; } /** * Clear all search filter highlights in the panel, and close the computed * list if toggled opened */ #clearHighlight(element) { for (const el of element.querySelectorAll(".ruleview-highlight")) { el.classList.remove("ruleview-highlight"); } for (const computed of element.querySelectorAll( ".ruleview-computedlist[filter-open]" )) { computed.parentNode._textPropertyEditor.collapseForFilter(); } } /** * Called when the pseudo class panel button is clicked and toggles * the display of the pseudo class panel. */ #onTogglePseudoClassPanel = () => { if (this.pseudoClassPanel.hidden) { this.showPseudoClassPanel(); } else { this.hidePseudoClassPanel(); } }; showPseudoClassPanel() { this.hideClassPanel(); this.pseudoClassToggle.setAttribute("aria-pressed", "true"); this.pseudoClassPanel.inert = false; this.pseudoClassPanel.hidden = false; } hidePseudoClassPanel() { this.pseudoClassToggle.setAttribute("aria-pressed", "false"); this.pseudoClassPanel.inert = true; this.pseudoClassPanel.hidden = true; } /** * Called when a pseudo class checkbox is clicked and toggles * the pseudo class for the current selected element. */ #onTogglePseudoClass = event => { const target = event.target; this.inspector.togglePseudoClass(target.value); }; /** * Called when the class panel button is clicked and toggles the display of the class * panel. */ #onToggleClassPanel = () => { if (this.classPanel.hidden) { this.showClassPanel(); } else { this.hideClassPanel(); } }; showClassPanel() { this.hidePseudoClassPanel(); this.classToggle.setAttribute("aria-pressed", "true"); this.classPanel.hidden = false; this.classListPreviewer.focusAddClassField(); } hideClassPanel() { this.classToggle.setAttribute("aria-pressed", "false"); this.classPanel.hidden = true; } /** * Handle the keypress event in the rule view. */ #onShortcut(name, event) { if (!event.target.closest("#sidebar-panel-ruleview")) { return; } if (name === "CmdOrCtrl+F") { this.searchField.focus(); event.preventDefault(); } else if ( (name === "Return" || name === "Space") && this.element.classList.contains("non-interactive") ) { event.preventDefault(); } else if ( name === "Escape" && event.target === this.searchField && this.#onClearSearch() ) { // Handle the search box's keypress event. If the escape key is pressed, // clear the search box field. event.preventDefault(); event.stopPropagation(); } } #onToggleLightColorSchemeSimulation = async () => { const shouldSimulateLightScheme = this.colorSchemeLightSimulationButton.getAttribute("aria-pressed") !== "true"; this.colorSchemeLightSimulationButton.setAttribute( "aria-pressed", shouldSimulateLightScheme ); this.colorSchemeDarkSimulationButton.setAttribute("aria-pressed", "false"); await this.inspector.commands.targetConfigurationCommand.updateConfiguration( { colorSchemeSimulation: shouldSimulateLightScheme ? "light" : null, } ); // Refresh the current element's rules in the panel. this.refreshPanel(); }; #onToggleDarkColorSchemeSimulation = async () => { const shouldSimulateDarkScheme = this.colorSchemeDarkSimulationButton.getAttribute("aria-pressed") !== "true"; this.colorSchemeDarkSimulationButton.setAttribute( "aria-pressed", shouldSimulateDarkScheme ); this.colorSchemeLightSimulationButton.setAttribute("aria-pressed", "false"); await this.inspector.commands.targetConfigurationCommand.updateConfiguration( { colorSchemeSimulation: shouldSimulateDarkScheme ? "dark" : null, } ); // Refresh the current element's rules in the panel. this.refreshPanel(); }; #onTogglePrintSimulation = async () => { const enabled = this.printSimulationButton.getAttribute("aria-pressed") !== "true"; this.printSimulationButton.setAttribute("aria-pressed", enabled); await this.inspector.commands.targetConfigurationCommand.updateConfiguration( { printSimulationEnabled: enabled, } ); // Refresh the current element's rules in the panel. this.refreshPanel(); }; /** * Temporarily flash the given element. * * @param {Element} element * The element. * @returns {Promise} Promise that resolves after the element was flashed-out */ #flashMutationCallback; #flashElement(element) { flashElementOn(element, { backgroundClass: "theme-bg-contrast", }); if (this.#flashMutationCallback) { this.#flashMutationCallback(); } return new Promise(resolve => { this.#flashMutationCallback = () => { flashElementOff(element, { backgroundClass: "theme-bg-contrast", }); this.#flashMutationCallback = null; resolve(); }; setTimeout(this.#flashMutationCallback, PROPERTY_FLASHING_DURATION); }); } /** * Scrolls to the top of either the rule or declaration. The view will try to scroll to * the rule if both can fit in the viewport. If not, then scroll to the declaration. * * @param {Element} rule * The rule to scroll to. * @param {Element|null} declaration * Optional. The declaration to scroll to. * @param {string} scrollBehavior * Optional. The transition animation when scrolling. If prefers-reduced-motion * system pref is set, then the scroll behavior will be overridden to "instant". */ #scrollToElement(rule, declaration, scrollBehavior = "smooth") { let elementToScrollTo = rule; if (declaration) { const { offsetTop, offsetHeight } = declaration; // Get the distance between both the rule and declaration. If the distance is // greater than the height of the rule view, then only scroll to the declaration. const distance = offsetTop + offsetHeight - rule.offsetTop; if (this.element.parentNode.offsetHeight <= distance) { elementToScrollTo = declaration; } } // Ensure that smooth scrolling is disabled when the user prefers reduced motion. const win = elementToScrollTo.documentGlobal; const reducedMotion = win.matchMedia("(prefers-reduced-motion)").matches; scrollBehavior = reducedMotion ? "instant" : scrollBehavior; elementToScrollTo.scrollIntoView({ behavior: scrollBehavior, }); } /** * Returns whether or not the rule is in the view * * @param {StyleRuleFront} ruleFront * @returns {boolean} */ hasRule(ruleFront) { return this.rules.some(r => r.domRule === ruleFront); } /** * Finds the specified TextProperty name in the rule view. If found, scroll to and * flash the TextProperty. * * @param {string} name * The property name to scroll to and highlight. * @param {object} options * @param {StyleRuleFront|undefined} options.ruleFront * An optional StyleRuleFront. When this is set, we will only look for the property * in this exact rule. * @param {Function|undefined} options.ruleValidator * An optional function that can be used to filter out rules we shouldn't look * into to find the property name. The function is called with a Rule object, * and the rule will be skipped if the function returns a falsy value. * @param {true|undefined} options.focusValue * An optional boolean that indicate that the declaration value should be focused. * If false (the default value), the declaration name will be focused. * @returns {boolean} true if the TextProperty name is found, and false otherwise. */ highlightProperty = async ( name, { ruleValidator, ruleFront, focusValue = false } = {} ) => { // First, let's clear any search we might have, as the property could be hidden this.#onClearSearch({ focusSearchField: false }); if (ruleFront) { const rule = this.rules.find(r => r.domRule === ruleFront); if (!rule) { console.error("Unable to find a rule for actor", ruleFront); return false; } const highlighted = await this.#maybeHighlightPropertyInRule({ name, rule, ruleValidator, // If ruleFront is passed, we might need to highlight a declaration that is actually // overridden (e.g. when calling this from a "matched selector" item in the // computed panel). matchOverridden: true, focusValue, }); if (!highlighted) { console.error("Unable to highlight rule", name, rule); return false; } return true; } for (const rule of this.rules) { if ( await this.#maybeHighlightPropertyInRule({ name, rule, ruleValidator, focusValue, }) ) { return true; } } // If the property is a CSS variable and we didn't find its declaration, it might // be a registered property if (this.#maybeHighlightCssRegisteredProperty(name)) { return true; } return false; }; /** * Finds the specified TextProperty name in a specific rule. If found, scroll to it and * flash/focus the TextProperty. * * @param {object} options * @param {string} options.name * The property name to scroll to and highlight. * @param {Rule} options.rule * The rule to find the property into * @param {Function|undefined} options.ruleValidator * An optional function that can be used to filter out rules we shouldn't look * into to find the property name. The function is called with a Rule object, * and the rule will be skipped if the function returns a falsy value. * @param {boolean|undefined} options.matchOverridden * If true, the declaration will be considered even if it is overridden. * @param {string} options.scrollBehavior * An optional string that will be used as `scrollIntoView` `behavior` option. * Note that this will be overridden to "instant" if the user prefers reduced motion. * @param {true|undefined} options.focusValue * An optional boolean that indicate that the declaration value should be focused. * If false (the default value), the declaration name will be focused. * @returns {boolean} true if the TextProperty name is found, and false otherwise. */ async #maybeHighlightPropertyInRule({ name, rule, ruleValidator, matchOverridden = false, focusValue = false, }) { const hasRuleValidator = typeof ruleValidator === "function"; if (hasRuleValidator && !ruleValidator(rule)) { return false; } let matchingTextPropComputed; // hasHigherPriorityThanEarlierProp (that we use in the loop), is expecting // the props to be iterated through in reverse. const textProps = rule.textProps.toReversed(); for (const textProp of textProps) { for (const computed of textProp.computed) { if ( computed.name === name && !textProp.invisible && textProp.enabled && (!computed.overridden || matchOverridden) && (!matchingTextPropComputed || this.elementStyle.hasHigherPriorityThanEarlierProp( computed, matchingTextPropComputed )) ) { matchingTextPropComputed = computed; } } } if (!matchingTextPropComputed) { return false; } await this.#selectRuleViewIfNeeded(); let scrollBehavior; // If the property is being applied by a pseudo element rule, expand the pseudo // element list container. if (rule.pseudoElement.length && !this.showPseudoElements) { // Set the scroll behavior to "instant" to avoid timing issues between toggling // the pseudo element container and scrolling smoothly to the rule. scrollBehavior = "instant"; this.#toggleContainerVisibility(PSEUDO_ELEMENTS_CONTAINER_ID, true, true); } const textProp = matchingTextPropComputed.textProp; // If we're jumping to an unused CSS variable, it might not be visible, so show // it here. if (!textProp.editor && textProp.isUnusedVariable) { textProp.rule.editor.showUnusedCssVariable(textProp); } // If the textProp is a shorthand, we need to expand the computed list so we can // highlight the proper element. const isTextPropAShorthand = textProp.name !== matchingTextPropComputed.name; if (isTextPropAShorthand) { textProp.editor.expandForFilter(); } this.#highlightElementInRule({ rule, elementToHighlight: isTextPropAShorthand ? // if the prop is a shorthand, we hand to highlight the exact longhand property matchingTextPropComputed.element : textProp.editor.element, elementToFocus: focusValue ? textProp.editor.valueSpan : textProp.editor.nameSpan, scrollBehavior, }); return true; } /** * If the passed name matches a registered CSS property highlight it * * @param {string} name - The name of the registered property to highlight * @param {string} scrollBehavior - An optional string that will be used as * `scrollIntoView` `behavior` option. * Note that this will be overridden to "instant" if the user prefers reduced motion. * @returns {boolean} Returns true if `name` matched a registered property */ #maybeHighlightCssRegisteredProperty(name, scrollBehavior) { if (!name.startsWith("--")) { return false; } // Get a potential @property section const entry = this.#containers.get(REGISTERED_PROPERTIES_CONTAINER_ID); if (!entry) { return false; } const { header, container } = entry; const propertyEl = container.querySelector(`[data-name="${name}"]`); if (!propertyEl) { return false; } const toggleButton = header.querySelector("button"); if (toggleButton.ariaExpanded === "false") { this.#toggleContainerVisibility(REGISTERED_PROPERTIES_CONTAINER_ID); } this.#highlightElementInRule({ elementToHighlight: propertyEl, scrollBehavior, }); return true; } /** * Highlight a given element in a rule editor * * @param {object} options * @param {Rule} options.rule * The rule the element to highlight is in. * @param {Element} options.elementToHighlight * The element to highlight. * @param {Element} options.elementToFocus * An optional element to focus. * @param {string} options.scrollBehavior * An optional string that will be used as `scrollIntoView` `behavior` option. * Note that this will be overridden to "instant" if the user prefers reduced motion. */ async #highlightElementInRule({ rule, elementToHighlight, elementToFocus, scrollBehavior, }) { if (rule) { this.#scrollToElement( rule.editor.selectorText, elementToHighlight, scrollBehavior ); } else { this.#scrollToElement(elementToHighlight, null, scrollBehavior); } if (elementToFocus) { elementToFocus.focus({ focusVisible: true, // Don't scroll when focusing, this is taken care of by the call to #scrollToElement preventScroll: true, }); } await this.#flashElement(elementToHighlight); this.emitForTests("element-highlighted", elementToHighlight); } /** * Returns a Map (keyed by name) of the registered * properties for the currently selected node document. * * @returns Map|null */ getRegisteredPropertiesForSelectedNodeTarget() { return this.cssRegisteredPropertiesByTarget.get( this.inspector.selection.nodeFront.targetFront ); } async #selectRuleViewIfNeeded() { // If three-pane mode is enable, or if the rule view is already the currently selected // tab, there's no need to do anything. if ( this.inspector.isThreePaneModeEnabled || this.inspector.sidebar.getCurrentTabID() === "ruleview" ) { return; } // Otherwise, we need to select the ruleview from the sidebar. This will update the // panel, so we need to wait for it be populatedr const onRefreshed = this.inspector.once("rule-view-refreshed"); await this.inspector.sidebar.select("ruleview"); await onRefreshed; } } class RuleViewTool { constructor(inspector, window) { this.inspector = inspector; this.document = window.document; this.view = new CssRuleView(this.inspector, this.document); this.refresh = this.refresh.bind(this); this.onDetachedFront = this.onDetachedFront.bind(this); this.onPanelSelected = this.onPanelSelected.bind(this); this.onDetachedFront = this.onDetachedFront.bind(this); this.onSelected = this.onSelected.bind(this); this.#abortController = new window.AbortController(); const { signal } = this.#abortController; const baseEventConfig = { signal }; this.inspector.selection.on( "detached-front", this.onDetachedFront, baseEventConfig ); this.inspector.selection.on( "new-node-front", this.onSelected, baseEventConfig ); this.inspector.selection.on("pseudoclass", this.refresh, baseEventConfig); this.inspector.ruleViewSideBar.on( "ruleview-selected", this.onPanelSelected, baseEventConfig ); this.inspector.sidebar.on( "ruleview-selected", this.onPanelSelected, baseEventConfig ); this.inspector.toolbox.on( "inspector-selected", this.onPanelSelected, baseEventConfig ); this.inspector.styleChangeTracker.on( "style-changed", this.refresh, baseEventConfig ); this.inspector.commands.resourceCommand.watchResources( [ this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT, this.inspector.commands.resourceCommand.TYPES.STYLESHEET, ], { onAvailable: this.#onResourceAvailable, ignoreExistingResources: true, } ); // We do want to get already existing registered properties, so we need to watch // them separately this.inspector.commands.resourceCommand .watchResources( [ this.inspector.commands.resourceCommand.TYPES .CSS_REGISTERED_PROPERTIES, ], { onAvailable: this.#onResourceAvailable, onUpdated: this.#onResourceUpdated, onDestroyed: this.#onResourceDestroyed, ignoreExistingResources: false, } ) .catch(e => { // watchResources is async and even making it's resulting promise part of // this.readyPromise still causes test failures, so simply ignore the rejection // if the view was already destroyed. if (!this.view) { return; } throw e; }); // At the moment `readyPromise` is only consumed in tests (see `openRuleView`) to be // notified when the ruleview was first populated to match the initial selected node. this.readyPromise = this.onSelected(); } #abortController; isPanelVisible() { if (!this.view) { return false; } return this.view.isPanelVisible(); } onDetachedFront() { this.onSelected(false); } async onSelected(selectElement = true) { // Ignore the event if the view has been destroyed, or if it's inactive. // But only if the current selection isn't null. If it's been set to null, // let the update go through as this is needed to empty the view on // navigation. if (!this.view) { return; } const isInactive = !this.isPanelVisible() && this.inspector.selection.nodeFront; if (isInactive) { return; } if ( !this.inspector.selection.isConnected() || !this.inspector.selection.isElementNode() ) { this.view.selectElement(null); return; } if (!selectElement) { return; } await this.view.selectElement(this.inspector.selection.nodeFront); } refresh() { if (this.isPanelVisible()) { this.view.refreshPanel(); } } #onResourceAvailable = resources => { if (!this.inspector) { return; } let hasNewStylesheet = false; const addedRegisteredProperties = []; for (const resource of resources) { if ( resource.resourceType === this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT && resource.name === "will-navigate" ) { this.view.cssRegisteredPropertiesByTarget.delete(resource.targetFront); if (resource.targetFront.isTopLevel) { this.clearUserProperties(); } continue; } if ( resource.resourceType === this.inspector.commands.resourceCommand.TYPES.STYLESHEET && // resource.isNew is only true when the stylesheet was added from DevTools, // for example when adding a rule in the rule view. In such cases, we're already // updating the rule view, so ignore those. !resource.isNew ) { hasNewStylesheet = true; } if ( resource.resourceType === this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES ) { if ( !this.view.cssRegisteredPropertiesByTarget.has(resource.targetFront) ) { this.view.cssRegisteredPropertiesByTarget.set( resource.targetFront, new Map() ); } this.view.cssRegisteredPropertiesByTarget .get(resource.targetFront) .set(resource.name, resource); // Only add properties from the same target as the selected node if ( this.view.inspector.selection?.nodeFront?.targetFront === resource.targetFront ) { addedRegisteredProperties.push(resource); } } } if (addedRegisteredProperties.length) { // Retrieve @property container const registeredPropertiesContainer = this.view.getOrCreateRegisteredPropertiesExpandableContainer(); // Then add all new registered properties const names = new Set(); for (const propertyDefinition of addedRegisteredProperties) { const editor = new RegisteredPropertyEditor( this.view, propertyDefinition ); names.add(propertyDefinition.name); // We need to insert the element at the right position so we keep the list of // properties alphabetically sorted. let referenceNode = null; for (const child of registeredPropertiesContainer.children) { if (child.getAttribute("data-name") > propertyDefinition.name) { referenceNode = child; break; } } registeredPropertiesContainer.insertBefore( editor.element, referenceNode ); } // Finally, update textProps that might rely on those new properties this.#updateElementStyleRegisteredProperties(names); } if (hasNewStylesheet) { this.refresh(); } }; #onResourceUpdated = updates => { const updatedProperties = []; for (const update of updates) { if ( update.resource.resourceType === this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES ) { const { resource } = update; if ( !this.view.cssRegisteredPropertiesByTarget.has(resource.targetFront) ) { continue; } this.view.cssRegisteredPropertiesByTarget .get(resource.targetFront) .set(resource.name, resource); // Only consider properties from the same target as the selected node if ( this.view.inspector.selection?.nodeFront?.targetFront === resource.targetFront ) { updatedProperties.push(resource); } } } const names = new Set(); if (updatedProperties.length) { const registeredPropertiesContainer = this.view.styleDocument.getElementById( REGISTERED_PROPERTIES_CONTAINER_ID ); for (const resource of updatedProperties) { // Replace the existing registered property editor element with a new one, // so we don't have to compute which elements should be updated. const name = resource.name; const el = this.view.getRegisteredPropertyElement(name); const editor = new RegisteredPropertyEditor(this.view, resource); registeredPropertiesContainer.replaceChild(editor.element, el); names.add(resource.name); } // Finally, update textProps that might rely on those new properties this.#updateElementStyleRegisteredProperties(names); } }; #onResourceDestroyed = resources => { const destroyedPropertiesNames = new Set(); for (const resource of resources) { if ( resource.resourceType === this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES ) { if ( !this.view.cssRegisteredPropertiesByTarget.has(resource.targetFront) ) { continue; } const targetRegisteredProperties = this.view.cssRegisteredPropertiesByTarget.get(resource.targetFront); const resourceName = Array.from( targetRegisteredProperties.entries() ).find( ([_, propDef]) => propDef.resourceId === resource.resourceId )?.[0]; if (!resourceName) { continue; } targetRegisteredProperties.delete(resourceName); // Only consider properties from the same target as the selected node if ( this.view.inspector.selection?.nodeFront?.targetFront === resource.targetFront ) { destroyedPropertiesNames.add(resourceName); } } } if (destroyedPropertiesNames.size > 0) { for (const name of destroyedPropertiesNames) { this.view.getRegisteredPropertyElement(name)?.remove(); } // Finally, update textProps that were relying on those removed properties this.#updateElementStyleRegisteredProperties(destroyedPropertiesNames); } }; /** * Update rules that reference registered properties whose name is in the passed Set, * so the `var()` tooltip has up-to-date information. * * @param {Set} registeredPropertyNames */ #updateElementStyleRegisteredProperties(registeredPropertyNames) { if (!this.view.elementStyle) { return; } this.view.elementStyle.onRegisteredPropertiesChange( registeredPropertyNames ); } clearUserProperties() { if (this.view && this.view.store && this.view.store.userProperties) { this.view.store.userProperties.clear(); } } onPanelSelected() { if (this.inspector.selection.nodeFront === this.view.selectedNodeFront) { this.refresh(); } else { this.onSelected(); } } destroy() { if (this.#abortController) { this.#abortController.abort(); } this.inspector.commands.resourceCommand.unwatchResources( [ this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT, this.inspector.commands.resourceCommand.TYPES.STYLESHEET, ], { onAvailable: this.#onResourceAvailable, } ); this.inspector.commands.resourceCommand.unwatchResources( [this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES], { onAvailable: this.#onResourceAvailable, onUpdated: this.#onResourceUpdated, onDestroyed: this.#onResourceDestroyed, } ); this.view.destroy(); this.view = this.document = this.inspector = this.readyPromise = this.store = this.#abortController = null; } } exports.CssRuleView = CssRuleView; exports.RuleViewTool = RuleViewTool;