diff options
Diffstat (limited to 'devtools/client/inspector/rules/rules.js')
-rw-r--r-- | devtools/client/inspector/rules/rules.js | 2199 |
1 files changed, 2199 insertions, 0 deletions
diff --git a/devtools/client/inspector/rules/rules.js b/devtools/client/inspector/rules/rules.js new file mode 100644 index 0000000000..5ddef719d5 --- /dev/null +++ b/devtools/client/inspector/rules/rules.js @@ -0,0 +1,2199 @@ +/* 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; |