/* 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, } = 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 TooltipsOverlay = require("resource://devtools/client/inspector/shared/tooltips-overlay.js"); const { createChild, promiseWarn, } = 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 DOUBLESPACE = " "; 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" ); 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 FILTER_CHANGED_TIMEOUT = 150; // Removes the flash-out class from an element after 1 second. const PROPERTY_FLASHING_DURATION = 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*$/; /** * 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. * * @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. */ function CssRuleView(inspector, document, store) { EventEmitter.decorate(this); this.inspector = inspector; this.cssProperties = inspector.cssProperties; this.styleDocument = document; this.styleWindow = this.styleDocument.defaultView; this.store = store || {}; // Allow tests to override debouncing behavior, as this can cause intermittents. this.debounce = debounce; // 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 this.childHasDragged = false; this._outputParser = new OutputParser(document, this.cssProperties); this._onAddRule = this._onAddRule.bind(this); this._onContextMenu = this._onContextMenu.bind(this); this._onCopy = this._onCopy.bind(this); this._onFilterStyles = this._onFilterStyles.bind(this); this._onClearSearch = this._onClearSearch.bind(this); this._onTogglePseudoClassPanel = this._onTogglePseudoClassPanel.bind(this); this._onTogglePseudoClass = this._onTogglePseudoClass.bind(this); this._onToggleClassPanel = this._onToggleClassPanel.bind(this); this._onToggleLightColorSchemeSimulation = this._onToggleLightColorSchemeSimulation.bind(this); this._onToggleDarkColorSchemeSimulation = this._onToggleDarkColorSchemeSimulation.bind(this); this._onTogglePrintSimulation = this._onTogglePrintSimulation.bind(this); this.highlightElementRule = this.highlightElementRule.bind(this); this.highlightProperty = this.highlightProperty.bind(this); this.refreshPanel = this.refreshPanel.bind(this); 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 }); 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.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._onShortcut = this._onShortcut.bind(this); 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); this.element.addEventListener("contextmenu", this._onContextMenu); this.addRuleButton.addEventListener("click", this._onAddRule); this.searchField.addEventListener("input", this._onFilterStyles); this.searchClearButton.addEventListener("click", this._onClearSearch); this.pseudoClassToggle.addEventListener( "click", this._onTogglePseudoClassPanel ); this.classToggle.addEventListener("click", this._onToggleClassPanel); // The "change" event bubbles up from checkbox inputs nested within the panel container. this.pseudoClassPanel.addEventListener("change", this._onTogglePseudoClass); 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 } ); } this._handlePrefChange = this._handlePrefChange.bind(this); this._handleUAStylePrefChange = this._handleUAStylePrefChange.bind(this); this._handleDefaultColorUnitPrefChange = this._handleDefaultColorUnitPrefChange.bind(this); this._handleDraggablePrefChange = this._handleDraggablePrefChange.bind(this); 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.pseudoClassCheckboxes = this._createPseudoClassCheckboxes(); this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES); // Add the tooltips and highlighters to the view this.tooltips = new TooltipsOverlay(this); } CssRuleView.prototype = { // The element that we're inspecting. _viewedElement: null, // Used for cancelling timeouts in the style filter. _filterChangedTimeout: null, // Empty, unconnected element of the same type as this node, used // to figure out how shorthand properties will be parsed. _dummyElement: null, 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; }, get classListPreviewer() { if (!this._classListPreviewer) { this._classListPreviewer = new ClassListPreviewer( this.inspector, this.classPanel ); } return this._classListPreviewer; }, get contextMenu() { if (!this._contextMenu) { this._contextMenu = new StyleInspectorMenu(this, { isRuleView: true }); } return this._contextMenu; }, // Get the dummy elemenet. get dummyElement() { return this._dummyElement; }, // Get the highlighters overlay from the Inspector. 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 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 selector will first unhighlight the previously * highlighted nodes. * Calling the method a second time with the same selector will just * unhighlight the highlighted nodes. * * @param {String} selector * Elements matching this selector will be highlighted on the page. */ async toggleSelectorHighlighter(selector) { if (this.isSelectorHighlighted(selector)) { await this.inspector.highlighters.hideHighlighterType( this.inspector.highlighters.TYPES.SELECTOR ); } else { await this.inspector.highlighters.showHighlighterTypeForNode( this.inspector.highlighters.TYPES.SELECTOR, this.inspector.selection.nodeFront, { hideInfoBar: true, hideGuides: true, selector, } ); } }, isPanelVisible() { if (this.inspector.is3PaneModeEnabled) { return true; } return ( this.inspector.toolbox && this.inspector.sidebar && this.inspector.toolbox.currentToolId === "inspector" && this.inspector.sidebar.getCurrentTabID() == "ruleview" ); }, /** * 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.selector; // dataset.selector will be empty for inline styles (inherited or not) // Rules associated with a regular selector should have this data-attirbute // set in devtools/client/inspector/rules/views/rule-editor.js if (selector === "") { try { const rule = getRuleFromNode(target, this._elementStyle); 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 in // dataset.selector for subsequent usage. target.dataset.selector = selector; } finally { // Could not resolve a unique selector for the inline style. } } this.toggleSelectorHighlighter(selector); } // 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" ); } }, /** * 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-selector='${selector}']`; for (const node of this.styleDocument.querySelectorAll(query)) { node.classList.toggle( "highlighted", eventName == "highlighter-shown" ); } } break; // Toggle the "active" class 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.classList.toggle("active", eventName == "highlighter-shown"); } } break; // Toggle the "active" 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.classList.toggle("active", 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"); this.printSimulationButton.addEventListener( "click", this._onTogglePrintSimulation ); this.colorSchemeLightSimulationButton.addEventListener( "click", this._onToggleLightColorSchemeSimulation ); this.colorSchemeDarkSimulationButton.addEventListener( "click", this._onToggleDarkColorSchemeSimulation ); }, /** * 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) { if ( event.originalTarget.closest("input[type=text]") || event.originalTarget.closest("input:not([type])") || event.originalTarget.closest("textarea") ) { 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"); // Replace 4 space indentation with 2 Spaces. text = text.replace(/\ {4}/g, DOUBLESPACE); } clipboardHelper.copyString(text); } catch (e) { console.error(e); } }, /** * Add a new rule to the current element. */ _onAddRule() { const elementStyle = this._elementStyle; const element = elementStyle.element; const pseudoClasses = element.pseudoClassLocks; // Adding a new rule with authored styles will cause the actor to // emit an event, which will in turn cause the rule view to be // updated. So, we wait for this update and for the rule creation // request to complete, and then focus the new rule's selector. const eventPromise = this.once("ruleview-refreshed"); const newRulePromise = this.pageStyle.addNewRule(element, pseudoClasses); Promise.all([eventPromise, newRulePromise]).then(values => { const options = values[1]; // Be sure the reference the correct |rules| here. for (const rule of this._elementStyle.rules) { if (options.rule === rule.domRule) { rule.editor.selectorText.click(); elementStyle._changed(); break; } } }); }, /** * Disables add rule button when needed */ refreshAddRuleButtonState() { const shouldBeDisabled = !this._viewedElement || !this.inspector.selection.isElementNode() || this.inspector.selection.isAnonymousNode(); this.addRuleButton.disabled = shouldBeDisabled; }, /** * 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"); }, _handlePrefChange(pref) { // Reselect the currently selected element const refreshOnPrefs = [PREF_UA_STYLES, PREF_DEFAULT_COLOR_UNIT]; if (refreshOnPrefs.indexOf(pref) > -1) { this.selectElement(this._viewedElement, true); } }, /** * Set the filter style search value. * @param {String} value * The search value. */ setFilterStyles(value = "") { this.searchField.value = value; this.searchField.focus(); this._onFilterStyles(); }, /** * Called when the user enters a search term in the filter style search box. */ _onFilterStyles() { if (this._filterChangedTimeout) { clearTimeout(this._filterChangedTimeout); } const filterTimeout = this.searchValue.length ? FILTER_CHANGED_TIMEOUT : 0; this.searchClearButton.hidden = this.searchValue.length === 0; this._filterChangedTimeout = setTimeout(() => { 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"); this._filterChangeTimeout = null; }, filterTimeout); }, /** * 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. */ _onClearSearch() { if (this.searchField.value) { this.setFilterStyles(""); return true; } return false; }, destroy() { this.isDestroyed = true; this.clear(); this._dummyElement = null; // off handlers must have the same reference as their on handlers this._prefObserver.off(PREF_UA_STYLES, this._handleUAStylePrefChange); this._prefObserver.off( PREF_DEFAULT_COLOR_UNIT, this._handleDefaultColorUnitPrefChange ); this._prefObserver.off(PREF_DRAGGABLE, this._handleDraggablePrefChange); 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; } // Clean-up for simulations. this.colorSchemeLightSimulationButton.removeEventListener( "click", this._onToggleLightColorSchemeSimulation ); this.colorSchemeDarkSimulationButton.removeEventListener( "click", this._onToggleDarkColorSchemeSimulation ); this.printSimulationButton.removeEventListener( "click", this._onTogglePrintSimulation ); this.colorSchemeLightSimulationButton = null; this.colorSchemeDarkSimulationButton = null; this.printSimulationButton = null; this.tooltips.destroy(); // Remove bound listeners this.shortcuts.destroy(); this.styleDocument.removeEventListener("click", this, { capture: true }); this.element.removeEventListener("copy", this._onCopy); this.element.removeEventListener("contextmenu", this._onContextMenu); this.addRuleButton.removeEventListener("click", this._onAddRule); this.searchField.removeEventListener("input", this._onFilterStyles); this.searchClearButton.removeEventListener("click", this._onClearSearch); this.pseudoClassPanel.removeEventListener( "change", this._onTogglePseudoClass ); this.pseudoClassToggle.removeEventListener( "click", this._onTogglePseudoClassPanel ); this.classToggle.removeEventListener("click", this._onToggleClassPanel); 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.classPanel = null; this.classToggle = null; this.inspector = null; this.styleDocument = null; this.styleWindow = null; if (this.element.parentNode) { this.element.remove(); } if (this._elementStyle) { this._elementStyle.destroy(); } 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"); }, /** * Update the view with a new selected element. * * @param {NodeActor} element * The node whose style rules we'll inspect. * @param {Boolean} allowRefresh * Update the view even if the element is the same as last time. */ selectElement(element, allowRefresh = false) { const refresh = this._viewedElement === element; if (refresh && !allowRefresh) { return Promise.resolve(undefined); } if (this._popup && this.popup.isOpen) { this.popup.hidePopup(); } this.clear(false); this._viewedElement = element; this.clearPseudoClassPanel(); this.refreshAddRuleButtonState(); if (!this._viewedElement) { this._stopSelectingElement(); this._clearRules(); this._showEmpty(); this.refreshPseudoClassPanel(); if (this.pageStyle) { this.pageStyle.off("stylesheet-updated", this.refreshPanel); this.pageStyle = null; } return Promise.resolve(undefined); } this.pageStyle = element.inspectorFront.pageStyle; this.pageStyle.on("stylesheet-updated", this.refreshPanel); // 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. const dummyElementPromise = Promise.resolve(this.styleDocument) .then(document => { // ::before and ::after do not have a namespaceURI const namespaceURI = this.element.namespaceURI || document.documentElement.namespaceURI; this._dummyElement = document.createElementNS( namespaceURI, this.element.tagName ); }) .catch(promiseWarn); const elementStyle = new ElementStyle( element, this, this.store, this.pageStyle, this.showUserAgentStyles ); this._elementStyle = elementStyle; this._startSelectingElement(); return dummyElementPromise .then(() => { if (this._elementStyle === elementStyle) { return this._populate(); } return undefined; }) .then(() => { if (this._elementStyle === elementStyle) { if (!refresh) { this.element.scrollTop = 0; } this._stopSelectingElement(); this._elementStyle.onChanged = () => { this._changed(); }; } }) .catch(e => { if (this._elementStyle === elementStyle) { this._stopSelectingElement(); this._clearRules(); } console.error(e); }); }, /** * Update the rules for the currently highlighted element. */ 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 Promise.resolve(undefined); } // 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); } } return Promise.all(promises).then(() => { return this._populate(); }); }, /** * Clear the pseudo class options panel by removing the checked and disabled * attributes for each checkbox. */ clearPseudoClassPanel() { this.pseudoClassCheckboxes.forEach(checkbox => { checkbox.checked = false; checkbox.disabled = false; }); }, /** * For each item in PSEUDO_CLASSES, create a checkbox input element for toggling a * pseudo-class on the selected element and append it to the pseudo-class panel. * * Returns an array with the checkbox input elements for pseudo-classes. * * @return {Array} */ _createPseudoClassCheckboxes() { const doc = this.styleDocument; const fragment = doc.createDocumentFragment(); for (const pseudo of PSEUDO_CLASSES) { const label = doc.createElement("label"); const checkbox = doc.createElement("input"); checkbox.setAttribute("tabindex", "-1"); checkbox.setAttribute("type", "checkbox"); checkbox.setAttribute("value", pseudo); label.append(checkbox, pseudo); fragment.append(label); } this.pseudoClassPanel.append(fragment); return Array.from( this.pseudoClassPanel.querySelectorAll("input[type=checkbox]") ); }, /** * Update the pseudo class options for the currently highlighted element. */ refreshPseudoClassPanel() { if (!this._elementStyle || !this.inspector.selection.isElementNode()) { this.pseudoClassCheckboxes.forEach(checkbox => { checkbox.disabled = true; }); return; } const pseudoClassLocks = this._elementStyle.element.pseudoClassLocks; this.pseudoClassCheckboxes.forEach(checkbox => { checkbox.disabled = false; checkbox.checked = pseudoClassLocks.includes(checkbox.value); }); }, _populate() { const elementStyle = this._elementStyle; return this._elementStyle .populate() .then(() => { if (this._elementStyle !== elementStyle || this.isDestroyed) { return null; } this._clearRules(); const onEditorsReady = this._createEditors(); this.refreshPseudoClassPanel(); // Notify anyone that cares that we refreshed. return onEditorsReady.then(() => { this.emit("ruleview-refreshed"); }, console.error); }) .catch(promiseWarn); }, /** * Show the user that the rule view has no node selected. */ _showEmpty() { if (this.styleDocument.getElementById("ruleview-no-results")) { return; } createChild(this.element, "div", { id: "ruleview-no-results", class: "devtools-sidepanel-no-result", textContent: l10n("rule.empty"), }); }, /** * Clear the rules. */ _clearRules() { this.element.innerHTML = ""; }, /** * Clear the rule view. */ clear(clearDom = true) { if (clearDom) { this._clearRules(); } this._viewedElement = null; if (this._elementStyle) { this._elementStyle.destroy(); this._elementStyle = null; } if (this.pageStyle) { this.pageStyle.off("stylesheet-updated", this.refreshPanel); this.pageStyle = null; } }, /** * Called when the user has made changes to the ElementStyle. * Emits an event that clients can listen to. */ _changed() { this.emit("ruleview-changed"); }, /** * Text for header that shows above rules for this element */ 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 */ get pseudoElementLabel() { if (this._pseudoElementLabel) { return this._pseudoElementLabel; } this._pseudoElementLabel = l10n("rule.pseudoElement"); return this._pseudoElementLabel; }, get showPseudoElements() { if (this._showPseudoElements === undefined) { this._showPseudoElements = Services.prefs.getBoolPref( "devtools.inspector.show_pseudo_elements" ); } return this._showPseudoElements; }, /** * Creates an expandable container in the rule view * * @param {String} label * The label for the container header * @param {Boolean} isPseudo * Whether or not the container will hold pseudo element rules * @return {DOMNode} The container element */ createExpandableContainer(label, isPseudo = false) { const header = this.styleDocument.createElementNS(HTML_NS, "div"); header.className = this._getRuleViewHeaderClassName(true); header.setAttribute("role", "heading"); header.textContent = label; const twisty = this.styleDocument.createElementNS(HTML_NS, "span"); twisty.className = "ruleview-expander theme-twisty"; twisty.setAttribute("open", "true"); twisty.setAttribute("role", "button"); twisty.setAttribute("aria-label", l10n("rule.twistyCollapse.label")); header.insertBefore(twisty, header.firstChild); this.element.appendChild(header); const container = this.styleDocument.createElementNS(HTML_NS, "div"); container.classList.add("ruleview-expandable-container"); container.hidden = false; this.element.appendChild(container); header.addEventListener("click", () => { this._toggleContainerVisibility( twisty, container, isPseudo, !this.showPseudoElements ); }); if (isPseudo) { container.id = "pseudo-elements-container"; twisty.id = "pseudo-elements-header-twisty"; this._toggleContainerVisibility( twisty, container, isPseudo, this.showPseudoElements ); } return container; }, /** * Toggle the visibility of an expandable container * * @param {DOMNode} twisty * Clickable toggle DOM Node * @param {DOMNode} container * Expandable container DOM Node * @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(twisty, container, isPseudo, showPseudo) { let isOpen = twisty.getAttribute("open"); 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; } if (isOpen) { twisty.removeAttribute("open"); twisty.setAttribute("aria-label", l10n("rule.twistyExpand.label")); } else { twisty.setAttribute("open", "true"); twisty.setAttribute("aria-label", l10n("rule.twistyCollapse.label")); } }, _getRuleViewHeaderClassName(isPseudo) { const baseClassName = "ruleview-header"; return isPseudo ? baseClassName + " ruleview-expandable-header" : baseClassName; }, /** * Creates editor UI for each of the rules in _elementStyle. */ // eslint-disable-next-line complexity _createEditors() { // Run through the current list of rules, attaching // their editors in order. Create editors if needed. let lastInheritedSource = ""; let lastKeyframes = null; let seenPseudoElement = false; let seenNormalElement = false; let seenSearchTerm = false; let container = null; if (!this._elementStyle.rules) { return Promise.resolve(); } const editorReadyPromises = []; for (const rule of this._elementStyle.rules) { if (rule.domRule.system) { continue; } // Initialize rule editor if (!rule.editor) { rule.editor = new RuleEditor(this, rule); editorReadyPromises.push(rule.editor.once("source-link-updated")); } // 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; } } // Only print header for this element if there are pseudo elements if (seenPseudoElement && !seenNormalElement && !rule.pseudoElement) { seenNormalElement = true; const div = this.styleDocument.createElementNS(HTML_NS, "div"); div.className = this._getRuleViewHeaderClassName(); div.setAttribute("role", "heading"); div.textContent = this.selectedElementLabel; this.element.appendChild(div); } const inheritedSource = rule.inherited; if (inheritedSource && inheritedSource !== lastInheritedSource) { const div = this.styleDocument.createElementNS(HTML_NS, "div"); div.className = this._getRuleViewHeaderClassName(); div.setAttribute("role", "heading"); div.setAttribute("aria-level", "3"); div.textContent = rule.inheritedSource; lastInheritedSource = inheritedSource; this.element.appendChild(div); } if (!seenPseudoElement && rule.pseudoElement) { seenPseudoElement = true; container = this.createExpandableContainer( this.pseudoElementLabel, true ); } const keyframes = rule.keyframes; if (keyframes && keyframes !== lastKeyframes) { lastKeyframes = keyframes; container = this.createExpandableContainer(rule.keyframesName); } rule.editor.element.setAttribute("role", "article"); if (container && (rule.pseudoElement || keyframes)) { container.appendChild(rule.editor.element); } else { this.element.appendChild(rule.editor.element); } } const searchBox = this.searchField.parentNode; searchBox.classList.toggle( "devtools-searchbox-no-match", this.searchValue && !seenSearchTerm ); return Promise.all(editorReadyPromises); }, /** * 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.editor)) { 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) { 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; } let isHighlighted = false; for (let i = 0; i < element.childNodes.length; i++) { const child = element.childNodes[i]; 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.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 {TextPropertyEditor} editor * The rule property TextPropertyEditor object. * @return {Boolean} true if the property or computed property was * highlighted, false otherwise. */ _highlightProperty(editor) { const isPropertyHighlighted = this._highlightRuleProperty(editor); const isComputedHighlighted = this._highlightComputedProperty(editor); // Expand the computed list if a computed property is highlighted and the // property rule is not highlighted if ( !isPropertyHighlighted && isComputedHighlighted && !editor.computed.hasAttribute("user-open") ) { 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)) { 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 {TextPropertyEditor} editor * The rule property TextPropertyEditor object. * @return {Boolean} true if the rule property was highlighted, * false otherwise. */ _highlightRuleProperty(editor) { // Get the actual property value displayed in the rule view const propertyName = editor.prop.name.toLowerCase(); const propertyValue = editor.valueSpan.textContent.toLowerCase(); return this._highlightMatches( editor.container, propertyName, propertyValue ); }, /** * Highlights the computed property that matches the filter search value and * returns a boolean indicating whether or not the computed property was * highlighted. * * @param {TextPropertyEditor} editor * The rule property TextPropertyEditor object. * @return {Boolean} true if the computed property was highlighted, false * otherwise. */ _highlightComputedProperty(editor) { let isComputedHighlighted = false; // Highlight search matches in the computed list of properties editor._populateComputed(); for (const computed of editor.prop.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( computed.element, computedName, computedValue ) ? 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 {DOMNode} element * The node to highlight if search terms match * @param {String} propertyName * The property name of a rule * @param {String} propertyValue * The property value of a rule * @return {Boolean} true if the given search terms match the property, false * otherwise. */ _highlightMatches(element, propertyName, propertyValue) { 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) { element.classList.add("ruleview-highlight"); } return matches; }, /** * 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.classList.add("checked"); this.pseudoClassCheckboxes.forEach(checkbox => { checkbox.setAttribute("tabindex", "0"); }); this.pseudoClassPanel.hidden = false; }, hidePseudoClassPanel() { this.pseudoClassToggle.classList.remove("checked"); this.pseudoClassCheckboxes.forEach(checkbox => { checkbox.setAttribute("tabindex", "-1"); }); 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.classList.add("checked"); this.classPanel.hidden = false; this.classListPreviewer.focusAddClassField(); }, hideClassPanel() { this.classToggle.classList.remove("checked"); 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(); } }, async _onToggleLightColorSchemeSimulation() { const shouldSimulateLightScheme = this.colorSchemeLightSimulationButton.classList.toggle("checked"); const darkColorSchemeEnabled = this.colorSchemeDarkSimulationButton.classList.contains("checked"); if (shouldSimulateLightScheme && darkColorSchemeEnabled) { this.colorSchemeDarkSimulationButton.classList.toggle("checked"); } await this.inspector.commands.targetConfigurationCommand.updateConfiguration( { colorSchemeSimulation: shouldSimulateLightScheme ? "light" : null, } ); // Refresh the current element's rules in the panel. this.refreshPanel(); }, async _onToggleDarkColorSchemeSimulation() { const shouldSimulateDarkScheme = this.colorSchemeDarkSimulationButton.classList.toggle("checked"); const lightColorSchemeEnabled = this.colorSchemeLightSimulationButton.classList.contains("checked"); if (shouldSimulateDarkScheme && lightColorSchemeEnabled) { this.colorSchemeLightSimulationButton.classList.toggle("checked"); } await this.inspector.commands.targetConfigurationCommand.updateConfiguration( { colorSchemeSimulation: shouldSimulateDarkScheme ? "dark" : null, } ); // Refresh the current element's rules in the panel. this.refreshPanel(); }, async _onTogglePrintSimulation() { const enabled = this.printSimulationButton.classList.toggle("checked"); 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. */ _flashElement(element) { flashElementOn(element, { backgroundClass: "theme-bg-contrast", }); if (this._flashMutationTimer) { clearTimeout(this._removeFlashOutTimer); this._flashMutationTimer = null; } this._flashMutationTimer = setTimeout(() => { flashElementOff(element, { backgroundClass: "theme-bg-contrast", }); // Emit "scrolled-to-property" for use by tests. this.emit("scrolled-to-element"); }, 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 "auto". */ _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.ownerGlobal; const reducedMotion = win.matchMedia("(prefers-reduced-motion)").matches; scrollBehavior = reducedMotion ? "auto" : scrollBehavior; elementToScrollTo.scrollIntoView({ behavior: scrollBehavior }); }, /** * Toggles the visibility of the pseudo element rule's container. */ _togglePseudoElementRuleContainer() { const container = this.styleDocument.getElementById( "pseudo-elements-container" ); const twisty = this.styleDocument.getElementById( "pseudo-elements-header-twisty" ); this._toggleContainerVisibility(twisty, container, true, true); }, /** * Finds the rule with the matching actorID and highlights it. * * @param {String} ruleId * The actorID of the rule. */ highlightElementRule(ruleId) { let scrollBehavior = "smooth"; const rule = this.rules.find(r => r.domRule.actorID === ruleId); if (!rule) { return; } if (rule.domRule.actorID === ruleId) { // If using 2-Pane mode, then switch to the Rules tab first. if (!this.inspector.is3PaneModeEnabled) { this.inspector.sidebar.select("ruleview"); } if (rule.pseudoElement.length && !this.showPseudoElements) { scrollBehavior = "auto"; this._togglePseudoElementRuleContainer(); } const { editor: { element }, } = rule; // Scroll to the top of the rule and highlight it. this._scrollToElement(element, null, scrollBehavior); this._flashElement(element); } }, /** * 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. * @return {Boolean} true if the TextProperty name is found, and false otherwise. */ highlightProperty(name) { for (const rule of this.rules) { for (const textProp of rule.textProps) { if (textProp.overridden || textProp.invisible || !textProp.enabled) { continue; } const { editor: { selectorText }, } = rule; let scrollBehavior = "smooth"; // First, search for a matching authored property. if (textProp.name === name) { // If using 2-Pane mode, then switch to the Rules tab first. if (!this.inspector.is3PaneModeEnabled) { this.inspector.sidebar.select("ruleview"); } // 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 "auto" to avoid timing issues between toggling // the pseudo element container and scrolling smoothly to the rule. scrollBehavior = "auto"; this._togglePseudoElementRuleContainer(); } // Scroll to the top of the property's rule so that both the property and its // rule are visible. this._scrollToElement( selectorText, textProp.editor.element, scrollBehavior ); this._flashElement(textProp.editor.element); return true; } // If there is no matching property, then look in computed properties. for (const computed of textProp.computed) { if (computed.overridden) { continue; } if (computed.name === name) { if (!this.inspector.is3PaneModeEnabled) { this.inspector.sidebar.select("ruleview"); } if ( textProp.rule.pseudoElement.length && !this.showPseudoElements ) { scrollBehavior = "auto"; this._togglePseudoElementRuleContainer(); } // Expand the computed list. textProp.editor.expandForFilter(); this._scrollToElement( selectorText, computed.element, scrollBehavior ); this._flashElement(computed.element); return true; } } } } return false; }, }; function RuleViewTool(inspector, window) { this.inspector = inspector; this.document = window.document; this.view = new CssRuleView(this.inspector, this.document); this._onResourceAvailable = this._onResourceAvailable.bind(this); 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.onViewRefreshed = this.onViewRefreshed.bind(this); this.view.on("ruleview-refreshed", this.onViewRefreshed); this.inspector.selection.on("detached-front", this.onDetachedFront); this.inspector.selection.on("new-node-front", this.onSelected); this.inspector.selection.on("pseudoclass", this.refresh); this.inspector.ruleViewSideBar.on("ruleview-selected", this.onPanelSelected); this.inspector.sidebar.on("ruleview-selected", this.onPanelSelected); this.inspector.styleChangeTracker.on("style-changed", this.refresh); this.inspector.commands.resourceCommand.watchResources( [this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: this._onResourceAvailable, ignoreExistingResources: true, } ); // 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(); } RuleViewTool.prototype = { isPanelVisible() { if (!this.view) { return false; } return this.view.isPanelVisible(); }, onDetachedFront() { this.onSelected(false); }, 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 null; } const isInactive = !this.isPanelVisible() && this.inspector.selection.nodeFront; if (isInactive) { return null; } if ( !this.inspector.selection.isConnected() || !this.inspector.selection.isElementNode() ) { return this.view.selectElement(null); } if (!selectElement) { return null; } const done = this.inspector.updating("rule-view"); return this.view .selectElement(this.inspector.selection.nodeFront) .then(done, done); }, refresh() { if (this.isPanelVisible()) { this.view.refreshPanel(); } }, _onResourceAvailable(resources) { for (const resource of resources) { if ( resource.resourceType === this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT && resource.name === "will-navigate" && resource.targetFront.isTopLevel ) { this.clearUserProperties(); } } }, clearUserProperties() { if (this.view && this.view.store && this.view.store.userProperties) { this.view.store.userProperties.clear(); } }, onPanelSelected() { if (this.inspector.selection.nodeFront === this.view._viewedElement) { this.refresh(); } else { this.onSelected(); } }, onViewRefreshed() { this.inspector.emit("rule-view-refreshed"); }, destroy() { this.inspector.styleChangeTracker.off("style-changed", this.refresh); this.inspector.selection.off("detached-front", this.onDetachedFront); this.inspector.selection.off("pseudoclass", this.refresh); this.inspector.selection.off("new-node-front", this.onSelected); this.inspector.currentTarget.off("navigate", this.clearUserProperties); this.inspector.sidebar.off("ruleview-selected", this.onPanelSelected); this.inspector.commands.resourceCommand.unwatchResources( [this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: this._onResourceAvailable, } ); this.view.off("ruleview-refreshed", this.onViewRefreshed); this.view.destroy(); this.view = this.document = this.inspector = this.readyPromise = null; }, }; exports.CssRuleView = CssRuleView; exports.RuleViewTool = RuleViewTool;