rules.js (90009B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const flags = require("resource://devtools/shared/flags.js"); 8 const { l10n } = require("resource://devtools/shared/inspector/css-logic.js"); 9 const { 10 style: { ELEMENT_STYLE }, 11 } = require("resource://devtools/shared/constants.js"); 12 const { 13 PSEUDO_CLASSES, 14 } = require("resource://devtools/shared/css/constants.js"); 15 const OutputParser = require("resource://devtools/client/shared/output-parser.js"); 16 const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); 17 const ElementStyle = require("resource://devtools/client/inspector/rules/models/element-style.js"); 18 const RuleEditor = require("resource://devtools/client/inspector/rules/views/rule-editor.js"); 19 const RegisteredPropertyEditor = require("resource://devtools/client/inspector/rules/views/registered-property-editor.js"); 20 const TooltipsOverlay = require("resource://devtools/client/inspector/shared/tooltips-overlay.js"); 21 const { 22 createChild, 23 promiseWarn, 24 } = require("resource://devtools/client/inspector/shared/utils.js"); 25 const { debounce } = require("resource://devtools/shared/debounce.js"); 26 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 27 28 loader.lazyRequireGetter( 29 this, 30 ["flashElementOn", "flashElementOff"], 31 "resource://devtools/client/inspector/markup/utils.js", 32 true 33 ); 34 loader.lazyRequireGetter( 35 this, 36 "ClassListPreviewer", 37 "resource://devtools/client/inspector/rules/views/class-list-previewer.js" 38 ); 39 loader.lazyRequireGetter( 40 this, 41 ["getNodeInfo", "getNodeCompatibilityInfo", "getRuleFromNode"], 42 "resource://devtools/client/inspector/rules/utils/utils.js", 43 true 44 ); 45 loader.lazyRequireGetter( 46 this, 47 "StyleInspectorMenu", 48 "resource://devtools/client/inspector/shared/style-inspector-menu.js" 49 ); 50 loader.lazyRequireGetter( 51 this, 52 "AutocompletePopup", 53 "resource://devtools/client/shared/autocomplete-popup.js" 54 ); 55 loader.lazyRequireGetter( 56 this, 57 "KeyShortcuts", 58 "resource://devtools/client/shared/key-shortcuts.js" 59 ); 60 loader.lazyRequireGetter( 61 this, 62 "clipboardHelper", 63 "resource://devtools/shared/platform/clipboard.js" 64 ); 65 loader.lazyRequireGetter( 66 this, 67 "openContentLink", 68 "resource://devtools/client/shared/link.js", 69 true 70 ); 71 72 const lazy = {}; 73 ChromeUtils.defineESModuleGetters(lazy, { 74 AppConstants: "resource://gre/modules/AppConstants.sys.mjs", 75 }); 76 77 const HTML_NS = "http://www.w3.org/1999/xhtml"; 78 const PREF_UA_STYLES = "devtools.inspector.showUserAgentStyles"; 79 const PREF_DEFAULT_COLOR_UNIT = "devtools.defaultColorUnit"; 80 const PREF_DRAGGABLE = "devtools.inspector.draggable_properties"; 81 const PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER = 82 "devtools.inspector.rule-view.focusNextOnEnter"; 83 const FILTER_CHANGED_TIMEOUT = 150; 84 // Removes the flash-out class from an element after 1 second (100ms in tests so they 85 // don't take too long to run). 86 const PROPERTY_FLASHING_DURATION = flags.testing ? 100 : 1000; 87 88 // This is used to parse user input when filtering. 89 const FILTER_PROP_RE = /\s*([^:\s]*)\s*:\s*(.*?)\s*;?$/; 90 // This is used to parse the filter search value to see if the filter 91 // should be strict or not 92 const FILTER_STRICT_RE = /\s*`(.*?)`\s*$/; 93 94 const RULE_VIEW_HEADER_CLASSNAME = "ruleview-header"; 95 const PSEUDO_ELEMENTS_CONTAINER_ID = "pseudo-elements-container"; 96 const REGISTERED_PROPERTIES_CONTAINER_ID = "registered-properties-container"; 97 const POSITION_TRY_CONTAINER_ID = "position-try-container"; 98 99 /** 100 * Our model looks like this: 101 * 102 * ElementStyle: 103 * Responsible for keeping track of which properties are overridden. 104 * Maintains a list of Rule objects that apply to the element. 105 * Rule: 106 * Manages a single style declaration or rule. 107 * Responsible for applying changes to the properties in a rule. 108 * Maintains a list of TextProperty objects. 109 * TextProperty: 110 * Manages a single property from the authoredText attribute of the 111 * relevant declaration. 112 * Maintains a list of computed properties that come from this 113 * property declaration. 114 * Changes to the TextProperty are sent to its related Rule for 115 * application. 116 * 117 * View hierarchy mostly follows the model hierarchy. 118 * 119 * CssRuleView: 120 * Owns an ElementStyle and creates a list of RuleEditors for its 121 * Rules. 122 * RuleEditor: 123 * Owns a Rule object and creates a list of TextPropertyEditors 124 * for its TextProperties. 125 * Manages creation of new text properties. 126 * TextPropertyEditor: 127 * Owns a TextProperty object. 128 * Manages changes to the TextProperty. 129 * Can be expanded to display computed properties. 130 * Can mark a property disabled or enabled. 131 */ 132 133 /** 134 * CssRuleView is a view of the style rules and declarations that 135 * apply to a given element. After construction, the 'element' 136 * property will be available with the user interface. 137 */ 138 class CssRuleView extends EventEmitter { 139 /** 140 * @param {Inspector} inspector 141 * Inspector toolbox panel 142 * @param {Document} document 143 * The document that will contain the rule view. 144 * @param {object} store 145 * The CSS rule view can use this object to store metadata 146 * that might outlast the rule view, particularly the current 147 * set of disabled properties. 148 */ 149 constructor(inspector, document, store) { 150 super(); 151 152 this.inspector = inspector; 153 this.cssProperties = inspector.cssProperties; 154 this.styleDocument = document; 155 this.styleWindow = this.styleDocument.defaultView; 156 this.store = store || { 157 expandedUnusedCustomCssPropertiesRuleActorIds: new Set(), 158 }; 159 160 // Allow tests to override debouncing behavior, as this can cause intermittents. 161 this.debounce = debounce; 162 163 // Variable used to stop the propagation of mouse events to children 164 // when we are updating a value by dragging the mouse and we then release it 165 this.childHasDragged = false; 166 167 this._outputParser = new OutputParser(document, this.cssProperties); 168 this._abortController = new this.styleWindow.AbortController(); 169 170 this.addNewRule = this.addNewRule.bind(this); 171 this._onContextMenu = this._onContextMenu.bind(this); 172 this._onCopy = this._onCopy.bind(this); 173 this._onFilterStyles = this._onFilterStyles.bind(this); 174 this._onClearSearch = this._onClearSearch.bind(this); 175 this._onTogglePseudoClassPanel = this._onTogglePseudoClassPanel.bind(this); 176 this._onTogglePseudoClass = this._onTogglePseudoClass.bind(this); 177 this._onToggleClassPanel = this._onToggleClassPanel.bind(this); 178 this._onToggleLightColorSchemeSimulation = 179 this._onToggleLightColorSchemeSimulation.bind(this); 180 this._onToggleDarkColorSchemeSimulation = 181 this._onToggleDarkColorSchemeSimulation.bind(this); 182 this._onTogglePrintSimulation = this._onTogglePrintSimulation.bind(this); 183 this.highlightProperty = this.highlightProperty.bind(this); 184 this.refreshPanel = this.refreshPanel.bind(this); 185 186 const doc = this.styleDocument; 187 // Delegate bulk handling of events happening within the DOM tree of the Rules view 188 // to this.handleEvent(). Listening on the capture phase of the event bubbling to be 189 // able to stop event propagation on a case-by-case basis and prevent event target 190 // ancestor nodes from handling them. 191 this.styleDocument.addEventListener("click", this, { capture: true }); 192 this.element = doc.getElementById("ruleview-container-focusable"); 193 this.addRuleButton = doc.getElementById("ruleview-add-rule-button"); 194 this.searchField = doc.getElementById("ruleview-searchbox"); 195 this.searchClearButton = doc.getElementById("ruleview-searchinput-clear"); 196 this.pseudoClassPanel = doc.getElementById("pseudo-class-panel"); 197 this.pseudoClassToggle = doc.getElementById("pseudo-class-panel-toggle"); 198 this.classPanel = doc.getElementById("ruleview-class-panel"); 199 this.classToggle = doc.getElementById("class-panel-toggle"); 200 this.colorSchemeLightSimulationButton = doc.getElementById( 201 "color-scheme-simulation-light-toggle" 202 ); 203 this.colorSchemeDarkSimulationButton = doc.getElementById( 204 "color-scheme-simulation-dark-toggle" 205 ); 206 this.printSimulationButton = doc.getElementById("print-simulation-toggle"); 207 208 this._initSimulationFeatures(); 209 210 this.searchClearButton.hidden = true; 211 212 this.onHighlighterShown = data => 213 this.handleHighlighterEvent("highlighter-shown", data); 214 this.onHighlighterHidden = data => 215 this.handleHighlighterEvent("highlighter-hidden", data); 216 this.inspector.highlighters.on( 217 "highlighter-shown", 218 this.onHighlighterShown 219 ); 220 this.inspector.highlighters.on( 221 "highlighter-hidden", 222 this.onHighlighterHidden 223 ); 224 225 this.shortcuts = new KeyShortcuts({ window: this.styleWindow }); 226 this._onShortcut = this._onShortcut.bind(this); 227 this.shortcuts.on("Escape", event => this._onShortcut("Escape", event)); 228 this.shortcuts.on("Return", event => this._onShortcut("Return", event)); 229 this.shortcuts.on("Space", event => this._onShortcut("Space", event)); 230 this.shortcuts.on("CmdOrCtrl+F", event => 231 this._onShortcut("CmdOrCtrl+F", event) 232 ); 233 this.element.addEventListener("copy", this._onCopy); 234 this.element.addEventListener("contextmenu", this._onContextMenu); 235 this.addRuleButton.addEventListener("click", this.addNewRule); 236 this.searchField.addEventListener("input", this._onFilterStyles); 237 this.searchClearButton.addEventListener("click", this._onClearSearch); 238 this.pseudoClassToggle.addEventListener( 239 "click", 240 this._onTogglePseudoClassPanel 241 ); 242 this.classToggle.addEventListener("click", this._onToggleClassPanel); 243 // The "change" event bubbles up from checkbox inputs nested within the panel container. 244 this.pseudoClassPanel.addEventListener("change", this._onTogglePseudoClass); 245 246 if (flags.testing) { 247 // In tests, we start listening immediately to avoid having to simulate a mousemove. 248 this.highlighters.addToView(this); 249 } else { 250 this.element.addEventListener( 251 "mousemove", 252 () => { 253 this.highlighters.addToView(this); 254 }, 255 { once: true } 256 ); 257 } 258 259 this._handlePrefChange = this._handlePrefChange.bind(this); 260 this._handleUAStylePrefChange = this._handleUAStylePrefChange.bind(this); 261 this._handleDefaultColorUnitPrefChange = 262 this._handleDefaultColorUnitPrefChange.bind(this); 263 this._handleDraggablePrefChange = 264 this._handleDraggablePrefChange.bind(this); 265 this._handleInplaceEditorFocusNextOnEnterPrefChange = 266 this._handleInplaceEditorFocusNextOnEnterPrefChange.bind(this); 267 268 this._prefObserver = new PrefObserver("devtools."); 269 this._prefObserver.on(PREF_UA_STYLES, this._handleUAStylePrefChange); 270 this._prefObserver.on( 271 PREF_DEFAULT_COLOR_UNIT, 272 this._handleDefaultColorUnitPrefChange 273 ); 274 this._prefObserver.on(PREF_DRAGGABLE, this._handleDraggablePrefChange); 275 // Initialize value of this.draggablePropertiesEnabled 276 this._handleDraggablePrefChange(); 277 278 this._prefObserver.on( 279 PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER, 280 this._handleInplaceEditorFocusNextOnEnterPrefChange 281 ); 282 // Initialize value of this.inplaceEditorFocusNextOnEnter 283 this._handleInplaceEditorFocusNextOnEnterPrefChange(); 284 285 this.pseudoClassCheckboxes = this._createPseudoClassCheckboxes(); 286 this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES); 287 288 // Add the tooltips and highlighters to the view 289 this.tooltips = new TooltipsOverlay(this); 290 291 this.cssRegisteredPropertiesByTarget = new Map(); 292 this._elementsWithPendingClicks = new this.styleWindow.WeakSet(); 293 } 294 295 // The element that we're inspecting. 296 _viewedElement = null; 297 298 // Used for cancelling timeouts in the style filter. 299 _filterChangedTimeout = null; 300 301 // Empty, unconnected element of the same type as this node, used 302 // to figure out how shorthand properties will be parsed. 303 _dummyElement = null; 304 305 get popup() { 306 if (!this._popup) { 307 // The popup will be attached to the toolbox document. 308 this._popup = new AutocompletePopup(this.inspector.toolbox.doc, { 309 autoSelect: true, 310 }); 311 } 312 313 return this._popup; 314 } 315 316 get classListPreviewer() { 317 if (!this._classListPreviewer) { 318 this._classListPreviewer = new ClassListPreviewer( 319 this.inspector, 320 this.classPanel 321 ); 322 } 323 324 return this._classListPreviewer; 325 } 326 327 get contextMenu() { 328 if (!this._contextMenu) { 329 this._contextMenu = new StyleInspectorMenu(this, { isRuleView: true }); 330 } 331 332 return this._contextMenu; 333 } 334 335 // Get the dummy elemenet. 336 get dummyElement() { 337 return this._dummyElement; 338 } 339 340 // Get the highlighters overlay from the Inspector. 341 get highlighters() { 342 if (!this._highlighters) { 343 // highlighters is a lazy getter in the inspector. 344 this._highlighters = this.inspector.highlighters; 345 } 346 347 return this._highlighters; 348 } 349 350 // Get the filter search value. 351 get searchValue() { 352 return this.searchField.value.toLowerCase(); 353 } 354 355 get rules() { 356 return this._elementStyle ? this._elementStyle.rules : []; 357 } 358 359 get currentTarget() { 360 return this.inspector.toolbox.target; 361 } 362 363 /** 364 * Highlight/unhighlight all the nodes that match a given rule's selector 365 * inside the document of the current selected node. 366 * Only one selector can be highlighted at a time, so calling the method a 367 * second time with a different rule will first unhighlight the previously 368 * highlighted nodes. 369 * Calling the method a second time with the same rule will just 370 * unhighlight the highlighted nodes. 371 * 372 * @param {Rule} rule 373 * @param {string} selector 374 * Elements matching this selector will be highlighted on the page. 375 * @param {boolean} highlightFromRulesSelector 376 */ 377 async toggleSelectorHighlighter( 378 rule, 379 selector, 380 highlightFromRulesSelector = true 381 ) { 382 if (this.isSelectorHighlighted(selector)) { 383 await this.inspector.highlighters.hideHighlighterType( 384 this.inspector.highlighters.TYPES.SELECTOR 385 ); 386 } else { 387 const options = { 388 hideInfoBar: true, 389 hideGuides: true, 390 // we still pass the selector (which can be the StyleRuleFront#computedSelector) 391 // even if highlightFromRulesSelector is set to true, as it's how we keep track 392 // of which selector is highlighted. 393 selector, 394 }; 395 if (highlightFromRulesSelector) { 396 options.ruleActorID = rule.domRule.actorID; 397 } 398 await this.inspector.highlighters.showHighlighterTypeForNode( 399 this.inspector.highlighters.TYPES.SELECTOR, 400 this.inspector.selection.nodeFront, 401 options 402 ); 403 } 404 } 405 406 isPanelVisible() { 407 return ( 408 this.inspector.toolbox && 409 this.inspector.sidebar && 410 this.inspector.toolbox.currentToolId === "inspector" && 411 (this.inspector.sidebar.getCurrentTabID() == "ruleview" || 412 this.inspector.isThreePaneModeEnabled) 413 ); 414 } 415 416 /** 417 * Check whether a SelectorHighlighter is active for the given selector text. 418 * 419 * @param {string} selector 420 * @return {boolean} 421 */ 422 isSelectorHighlighted(selector) { 423 const options = this.inspector.highlighters.getOptionsForActiveHighlighter( 424 this.inspector.highlighters.TYPES.SELECTOR 425 ); 426 427 return options?.selector === selector; 428 } 429 430 /** 431 * Delegate handler for events happening within the DOM tree of the Rules view. 432 * Itself delegates to specific handlers by event type. 433 * 434 * Use this instead of attaching specific event handlers when: 435 * - there are many elements with the same event handler (eases memory pressure) 436 * - you want to avoid having to remove event handlers manually 437 * - elements are added/removed from the DOM tree arbitrarily over time 438 * 439 * @param {MouseEvent|UIEvent} event 440 */ 441 handleEvent(event) { 442 if (this.childHasDragged) { 443 this.childHasDragged = false; 444 event.stopPropagation(); 445 return; 446 } 447 switch (event.type) { 448 case "click": 449 this.handleClickEvent(event); 450 break; 451 default: 452 } 453 } 454 455 /** 456 * Delegate handler for click events happening within the DOM tree of the Rules view. 457 * Stop propagation of click event wrapping a CSS rule or CSS declaration to avoid 458 * triggering the prompt to add a new CSS declaration or to edit the existing one. 459 * 460 * @param {MouseEvent} event 461 */ 462 async handleClickEvent(event) { 463 const target = event.target; 464 465 // Handle click on the icon next to a CSS selector. 466 if (target.classList.contains("js-toggle-selector-highlighter")) { 467 event.stopPropagation(); 468 let selector = target.dataset.computedSelector; 469 const highlightFromRulesSelector = 470 !!selector && !target.dataset.isUniqueSelector; 471 // dataset.computedSelector will be initially empty for inline styles (inherited or not) 472 // Rules associated with a regular selector should have this data-attribute 473 // set in devtools/client/inspector/rules/views/rule-editor.js 474 const rule = getRuleFromNode(target, this._elementStyle); 475 if (selector === "") { 476 try { 477 if (rule.inherited) { 478 // This is an inline style from an inherited rule. Need to resolve the 479 // unique selector from the node which this rule is inherited from. 480 selector = await rule.inherited.getUniqueSelector(); 481 } else { 482 // This is an inline style from the current node. 483 selector = 484 await this.inspector.selection.nodeFront.getUniqueSelector(); 485 } 486 487 // Now that the selector was computed, we can store it for subsequent usage. 488 target.dataset.computedSelector = selector; 489 target.dataset.isUniqueSelector = true; 490 } finally { 491 // Could not resolve a unique selector for the inline style. 492 } 493 } 494 495 this.toggleSelectorHighlighter( 496 rule, 497 selector, 498 highlightFromRulesSelector 499 ); 500 } 501 502 // Handle click on swatches next to flex and inline-flex CSS properties 503 if (target.classList.contains("js-toggle-flexbox-highlighter")) { 504 event.stopPropagation(); 505 this.inspector.highlighters.toggleFlexboxHighlighter( 506 this.inspector.selection.nodeFront, 507 "rule" 508 ); 509 } 510 511 // Handle click on swatches next to grid CSS properties 512 if (target.classList.contains("js-toggle-grid-highlighter")) { 513 event.stopPropagation(); 514 this.inspector.highlighters.toggleGridHighlighter( 515 this.inspector.selection.nodeFront, 516 "rule" 517 ); 518 } 519 520 const valueSpan = target.closest(".ruleview-propertyvalue"); 521 if (valueSpan) { 522 if (this._elementsWithPendingClicks.has(valueSpan)) { 523 // When we start handling a drag in the TextPropertyEditor valueSpan, 524 // we make the valueSpan capture the pointer. Then, `click` event target is always 525 // the valueSpan with the latest spec of Pointer Events. 526 // Therefore, we should stop immediate propagation of the `click` event 527 // if we've handled a drag to prevent moving focus to the inplace editor. 528 event.stopImmediatePropagation(); 529 return; 530 } 531 532 // Handle link click in RuleEditor property value 533 if (target.nodeName === "a") { 534 event.stopPropagation(); 535 event.preventDefault(); 536 openContentLink(target.href, { 537 relatedToCurrent: true, 538 inBackground: 539 event.button === 1 || 540 (lazy.AppConstants.platform === "macosx" 541 ? event.metaKey 542 : event.ctrlKey), 543 }); 544 } 545 } 546 } 547 548 /** 549 * Delegate handler for highlighter events. 550 * 551 * This is the place to observe for highlighter events, check the highlighter type and 552 * event name, then react to specific events, for example by modifying the DOM. 553 * 554 * @param {string} eventName 555 * Highlighter event name. One of: "highlighter-hidden", "highlighter-shown" 556 * @param {object} data 557 * Object with data associated with the highlighter event. 558 */ 559 handleHighlighterEvent(eventName, data) { 560 switch (data.type) { 561 // Toggle the "highlighted" class on selector icons in the Rules view when 562 // the SelectorHighlighter is shown/hidden for a certain CSS selector. 563 case this.inspector.highlighters.TYPES.SELECTOR: 564 { 565 const selector = data?.options?.selector; 566 if (!selector) { 567 return; 568 } 569 570 const query = `.js-toggle-selector-highlighter[data-computed-selector='${selector}']`; 571 for (const node of this.styleDocument.querySelectorAll(query)) { 572 const isHighlighterDisplayed = eventName == "highlighter-shown"; 573 node.classList.toggle("highlighted", isHighlighterDisplayed); 574 node.setAttribute("aria-pressed", isHighlighterDisplayed); 575 } 576 } 577 break; 578 579 // Toggle the "aria-pressed" attribute on swatches next to flex and inline-flex CSS properties 580 // when the FlexboxHighlighter is shown/hidden for the currently selected node. 581 case this.inspector.highlighters.TYPES.FLEXBOX: 582 { 583 const query = ".js-toggle-flexbox-highlighter"; 584 for (const node of this.styleDocument.querySelectorAll(query)) { 585 node.setAttribute("aria-pressed", eventName == "highlighter-shown"); 586 } 587 } 588 break; 589 590 // Toggle the "aria-pressed" class on swatches next to grid CSS properties 591 // when the GridHighlighter is shown/hidden for the currently selected node. 592 case this.inspector.highlighters.TYPES.GRID: 593 { 594 const query = ".js-toggle-grid-highlighter"; 595 for (const node of this.styleDocument.querySelectorAll(query)) { 596 // From the Layout panel, we can toggle grid highlighters for nodes which are 597 // not currently selected. The Rules view shows `display: grid` declarations 598 // only for the selected node. Avoid mistakenly marking them as "active". 599 if (data.nodeFront === this.inspector.selection.nodeFront) { 600 node.setAttribute( 601 "aria-pressed", 602 eventName == "highlighter-shown" 603 ); 604 } 605 606 // When the max limit of grid highlighters is reached (default 3), 607 // mark inactive grid swatches as disabled. 608 node.toggleAttribute( 609 "disabled", 610 !this.inspector.highlighters.canGridHighlighterToggle( 611 this.inspector.selection.nodeFront 612 ) 613 ); 614 } 615 } 616 break; 617 } 618 } 619 620 /** 621 * Enables the print and color scheme simulation only for local and remote tab debugging. 622 */ 623 async _initSimulationFeatures() { 624 if (!this.inspector.commands.descriptorFront.isTabDescriptor) { 625 return; 626 } 627 this.colorSchemeLightSimulationButton.removeAttribute("hidden"); 628 this.colorSchemeDarkSimulationButton.removeAttribute("hidden"); 629 this.printSimulationButton.removeAttribute("hidden"); 630 this.printSimulationButton.addEventListener( 631 "click", 632 this._onTogglePrintSimulation 633 ); 634 this.colorSchemeLightSimulationButton.addEventListener( 635 "click", 636 this._onToggleLightColorSchemeSimulation 637 ); 638 this.colorSchemeDarkSimulationButton.addEventListener( 639 "click", 640 this._onToggleDarkColorSchemeSimulation 641 ); 642 const { rfpCSSColorScheme } = this.inspector.walker; 643 if (rfpCSSColorScheme) { 644 this.colorSchemeLightSimulationButton.setAttribute("disabled", true); 645 this.colorSchemeDarkSimulationButton.setAttribute("disabled", true); 646 console.warn("Color scheme simulation is disabled in RFP mode."); 647 } 648 } 649 650 /** 651 * Get the type of a given node in the rule-view 652 * 653 * @param {DOMNode} node 654 * The node which we want information about 655 * @return {object | null} containing the following props: 656 * - type {String} One of the VIEW_NODE_XXX_TYPE const in 657 * client/inspector/shared/node-types. 658 * - rule {Rule} The Rule object. 659 * - value {Object} Depends on the type of the node. 660 * Otherwise, returns null if the node isn't anything we care about. 661 */ 662 getNodeInfo(node) { 663 return getNodeInfo(node, this._elementStyle); 664 } 665 666 /** 667 * Get the node's compatibility issues 668 * 669 * @param {DOMNode} node 670 * The node which we want information about 671 * @return {object | null} containing the following props: 672 * - type {String} Compatibility issue type. 673 * - property {string} The incompatible rule 674 * - alias {Array} The browser specific alias of rule 675 * - url {string} Link to MDN documentation 676 * - deprecated {bool} True if the rule is deprecated 677 * - experimental {bool} True if rule is experimental 678 * - unsupportedBrowsers {Array} Array of unsupported browser 679 * Otherwise, returns null if the node has cross-browser compatible CSS 680 */ 681 async getNodeCompatibilityInfo(node) { 682 const compatibilityInfo = await getNodeCompatibilityInfo( 683 node, 684 this._elementStyle 685 ); 686 687 return compatibilityInfo; 688 } 689 690 /** 691 * Context menu handler. 692 */ 693 _onContextMenu(event) { 694 if ( 695 event.originalTarget.closest("input[type=text]") || 696 event.originalTarget.closest("input:not([type])") || 697 event.originalTarget.closest("textarea") 698 ) { 699 return; 700 } 701 702 event.stopPropagation(); 703 event.preventDefault(); 704 705 this.contextMenu.show(event); 706 } 707 708 /** 709 * Callback for copy event. Copy the selected text. 710 * 711 * @param {Event} event 712 * copy event object. 713 */ 714 _onCopy(event) { 715 if (event) { 716 this.copySelection(event.target); 717 event.preventDefault(); 718 event.stopPropagation(); 719 } 720 } 721 722 /** 723 * Copy the current selection. The current target is necessary 724 * if the selection is inside an input or a textarea 725 * 726 * @param {DOMNode} target 727 * DOMNode target of the copy action 728 */ 729 copySelection(target) { 730 try { 731 let text = ""; 732 733 const nodeName = target?.nodeName; 734 const targetType = target?.type; 735 736 if ( 737 // The target can be the enable/disable rule checkbox here (See Bug 1680893). 738 (nodeName === "input" && targetType !== "checkbox") || 739 nodeName == "textarea" 740 ) { 741 const start = Math.min(target.selectionStart, target.selectionEnd); 742 const end = Math.max(target.selectionStart, target.selectionEnd); 743 const count = end - start; 744 text = target.value.substr(start, count); 745 } else { 746 text = this.styleWindow.getSelection().toString(); 747 748 // Remove any double newlines. 749 text = text.replace(/(\r?\n)\r?\n/g, "$1"); 750 } 751 752 clipboardHelper.copyString(text); 753 } catch (e) { 754 console.error(e); 755 } 756 } 757 758 /** 759 * Add a new rule to the current element. 760 */ 761 addNewRule() { 762 const elementStyle = this._elementStyle; 763 const element = elementStyle.element; 764 const pseudoClasses = element.pseudoClassLocks; 765 766 // Clear the search input so the new rule is visible 767 this._onClearSearch(); 768 769 this._focusNextUserAddedRule = true; 770 this.pageStyle.addNewRule(element, pseudoClasses); 771 } 772 773 /** 774 * Returns true if the "Add Rule" action (either via the addRuleButton or the context 775 * menu entry) can be performed for the currently selected node. 776 * 777 * @returns {boolean} 778 */ 779 canAddNewRuleForSelectedNode() { 780 return this._viewedElement && this.inspector.selection.isElementNode(); 781 } 782 783 /** 784 * Disables add rule button when needed 785 */ 786 refreshAddRuleButtonState() { 787 this.addRuleButton.disabled = !this.canAddNewRuleForSelectedNode(); 788 } 789 790 /** 791 * Return {Boolean} true if the rule view currently has an input 792 * editor visible. 793 */ 794 get isEditing() { 795 return ( 796 this.tooltips.isEditing || 797 !!this.element.querySelectorAll(".styleinspector-propertyeditor").length 798 ); 799 } 800 801 _handleUAStylePrefChange() { 802 this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES); 803 this._handlePrefChange(PREF_UA_STYLES); 804 } 805 806 _handleDefaultColorUnitPrefChange() { 807 this._handlePrefChange(PREF_DEFAULT_COLOR_UNIT); 808 } 809 810 _handleDraggablePrefChange() { 811 this.draggablePropertiesEnabled = Services.prefs.getBoolPref( 812 PREF_DRAGGABLE, 813 false 814 ); 815 // This event is consumed by text-property-editor instances in order to 816 // update their draggable behavior. Preferences observer are costly, so 817 // we are forwarding the preference update via the EventEmitter. 818 this.emit("draggable-preference-updated"); 819 } 820 821 _handleInplaceEditorFocusNextOnEnterPrefChange() { 822 this.inplaceEditorFocusNextOnEnter = Services.prefs.getBoolPref( 823 PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER, 824 false 825 ); 826 this._handlePrefChange(PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER); 827 } 828 829 _handlePrefChange(pref) { 830 // Reselect the currently selected element 831 const refreshOnPrefs = [ 832 PREF_UA_STYLES, 833 PREF_DEFAULT_COLOR_UNIT, 834 PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER, 835 ]; 836 if (this._viewedElement && refreshOnPrefs.includes(pref)) { 837 this.selectElement(this._viewedElement, true); 838 } 839 } 840 841 /** 842 * Set the filter style search value. 843 * 844 * @param {string} value 845 * The search value. 846 */ 847 setFilterStyles(value = "") { 848 this.searchField.value = value; 849 this.searchField.focus(); 850 this._onFilterStyles(); 851 } 852 853 /** 854 * Called when the user enters a search term in the filter style search box. 855 * The actual filtering (done in _doFilterStyles) will be throttled if the search input 856 * isn't empty, but will happen immediately when the search gets cleared. 857 */ 858 _onFilterStyles() { 859 if (this._filterChangedTimeout) { 860 clearTimeout(this._filterChangedTimeout); 861 } 862 863 const isSearchEmpty = this.searchValue.length === 0; 864 this.searchClearButton.hidden = isSearchEmpty; 865 866 // If the search is cleared update the UI directly so calls to this function (or any 867 // callsite of it) can assume the UI is up to date directly after the call. 868 if (isSearchEmpty) { 869 this._doFilterStyles(); 870 } else { 871 this._filterChangedTimeout = setTimeout( 872 () => this._doFilterStyles(), 873 FILTER_CHANGED_TIMEOUT 874 ); 875 } 876 } 877 878 /** 879 * Actually update search data and update the UI to reflect the current search. 880 * 881 * @fires ruleview-filtered 882 */ 883 _doFilterStyles() { 884 this.searchData = { 885 searchPropertyMatch: FILTER_PROP_RE.exec(this.searchValue), 886 searchPropertyName: this.searchValue, 887 searchPropertyValue: this.searchValue, 888 strictSearchValue: "", 889 strictSearchPropertyName: false, 890 strictSearchPropertyValue: false, 891 strictSearchAllValues: false, 892 }; 893 894 if (this.searchData.searchPropertyMatch) { 895 // Parse search value as a single property line and extract the 896 // property name and value. If the parsed property name or value is 897 // contained in backquotes (`), extract the value within the backquotes 898 // and set the corresponding strict search for the property to true. 899 if (FILTER_STRICT_RE.test(this.searchData.searchPropertyMatch[1])) { 900 this.searchData.strictSearchPropertyName = true; 901 this.searchData.searchPropertyName = FILTER_STRICT_RE.exec( 902 this.searchData.searchPropertyMatch[1] 903 )[1]; 904 } else { 905 this.searchData.searchPropertyName = 906 this.searchData.searchPropertyMatch[1]; 907 } 908 909 if (FILTER_STRICT_RE.test(this.searchData.searchPropertyMatch[2])) { 910 this.searchData.strictSearchPropertyValue = true; 911 this.searchData.searchPropertyValue = FILTER_STRICT_RE.exec( 912 this.searchData.searchPropertyMatch[2] 913 )[1]; 914 } else { 915 this.searchData.searchPropertyValue = 916 this.searchData.searchPropertyMatch[2]; 917 } 918 919 // Strict search for stylesheets will match the property line regex. 920 // Extract the search value within the backquotes to be used 921 // in the strict search for stylesheets in _highlightStyleSheet. 922 if (FILTER_STRICT_RE.test(this.searchValue)) { 923 this.searchData.strictSearchValue = FILTER_STRICT_RE.exec( 924 this.searchValue 925 )[1]; 926 } 927 } else if (FILTER_STRICT_RE.test(this.searchValue)) { 928 // If the search value does not correspond to a property line and 929 // is contained in backquotes, extract the search value within the 930 // backquotes and set the flag to perform a strict search for all 931 // the values (selector, stylesheet, property and computed values). 932 const searchValue = FILTER_STRICT_RE.exec(this.searchValue)[1]; 933 this.searchData.strictSearchAllValues = true; 934 this.searchData.searchPropertyName = searchValue; 935 this.searchData.searchPropertyValue = searchValue; 936 this.searchData.strictSearchValue = searchValue; 937 } 938 939 this._clearHighlight(this.element); 940 this._clearRules(); 941 this._createEditors(); 942 943 this.inspector.emit("ruleview-filtered"); 944 945 this._filterChangeTimeout = null; 946 } 947 948 /** 949 * Called when the user clicks on the clear button in the filter style search 950 * box. Returns true if the search box is cleared and false otherwise. 951 */ 952 _onClearSearch() { 953 if (this.searchField.value) { 954 this.setFilterStyles(""); 955 return true; 956 } 957 958 return false; 959 } 960 961 destroy() { 962 this.isDestroyed = true; 963 this.clear(); 964 965 this._dummyElement = null; 966 // off handlers must have the same reference as their on handlers 967 this._prefObserver.off(PREF_UA_STYLES, this._handleUAStylePrefChange); 968 this._prefObserver.off( 969 PREF_DEFAULT_COLOR_UNIT, 970 this._handleDefaultColorUnitPrefChange 971 ); 972 this._prefObserver.off(PREF_DRAGGABLE, this._handleDraggablePrefChange); 973 this._prefObserver.off( 974 PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER, 975 this._handleInplaceEditorFocusNextOnEnterPrefChange 976 ); 977 this._prefObserver.destroy(); 978 979 this._outputParser = null; 980 981 if (this._classListPreviewer) { 982 this._classListPreviewer.destroy(); 983 this._classListPreviewer = null; 984 } 985 986 if (this._contextMenu) { 987 this._contextMenu.destroy(); 988 this._contextMenu = null; 989 } 990 991 if (this._highlighters) { 992 this._highlighters.removeFromView(this); 993 this._highlighters = null; 994 } 995 996 // Clean-up for simulations. 997 this.colorSchemeLightSimulationButton.removeEventListener( 998 "click", 999 this._onToggleLightColorSchemeSimulation 1000 ); 1001 this.colorSchemeDarkSimulationButton.removeEventListener( 1002 "click", 1003 this._onToggleDarkColorSchemeSimulation 1004 ); 1005 this.printSimulationButton.removeEventListener( 1006 "click", 1007 this._onTogglePrintSimulation 1008 ); 1009 1010 this.colorSchemeLightSimulationButton = null; 1011 this.colorSchemeDarkSimulationButton = null; 1012 this.printSimulationButton = null; 1013 1014 this.tooltips.destroy(); 1015 1016 // Remove bound listeners 1017 this._abortController.abort(); 1018 this._abortController = null; 1019 this.shortcuts.destroy(); 1020 this.styleDocument.removeEventListener("click", this, { capture: true }); 1021 this.element.removeEventListener("copy", this._onCopy); 1022 this.element.removeEventListener("contextmenu", this._onContextMenu); 1023 this.addRuleButton.removeEventListener("click", this.addNewRule); 1024 this.searchField.removeEventListener("input", this._onFilterStyles); 1025 this.searchClearButton.removeEventListener("click", this._onClearSearch); 1026 this.pseudoClassPanel.removeEventListener( 1027 "change", 1028 this._onTogglePseudoClass 1029 ); 1030 this.pseudoClassToggle.removeEventListener( 1031 "click", 1032 this._onTogglePseudoClassPanel 1033 ); 1034 this.classToggle.removeEventListener("click", this._onToggleClassPanel); 1035 this.inspector.highlighters.off( 1036 "highlighter-shown", 1037 this.onHighlighterShown 1038 ); 1039 this.inspector.highlighters.off( 1040 "highlighter-hidden", 1041 this.onHighlighterHidden 1042 ); 1043 1044 this.searchField = null; 1045 this.searchClearButton = null; 1046 this.pseudoClassPanel = null; 1047 this.pseudoClassToggle = null; 1048 this.pseudoClassCheckboxes = null; 1049 this.classPanel = null; 1050 this.classToggle = null; 1051 1052 this.inspector = null; 1053 this.styleDocument = null; 1054 this.styleWindow = null; 1055 1056 if (this.element.parentNode) { 1057 this.element.remove(); 1058 } 1059 1060 if (this._elementStyle) { 1061 this._elementStyle.destroy(); 1062 } 1063 1064 if (this._popup) { 1065 this._popup.destroy(); 1066 this._popup = null; 1067 } 1068 } 1069 1070 /** 1071 * Mark the view as selecting an element, disabling all interaction, and 1072 * visually clearing the view after a few milliseconds to avoid confusion 1073 * about which element's styles the rule view shows. 1074 */ 1075 _startSelectingElement() { 1076 this.element.classList.add("non-interactive"); 1077 } 1078 1079 /** 1080 * Mark the view as no longer selecting an element, re-enabling interaction. 1081 */ 1082 _stopSelectingElement() { 1083 this.element.classList.remove("non-interactive"); 1084 } 1085 1086 /** 1087 * Update the view with a new selected element. 1088 * 1089 * @param {NodeActor} element 1090 * The node whose style rules we'll inspect. 1091 * @param {boolean} allowRefresh 1092 * Update the view even if the element is the same as last time. 1093 */ 1094 selectElement(element, allowRefresh = false) { 1095 const refresh = this._viewedElement === element; 1096 if (refresh && !allowRefresh) { 1097 return Promise.resolve(undefined); 1098 } 1099 1100 if (this._popup && this.popup.isOpen) { 1101 this.popup.hidePopup(); 1102 } 1103 1104 this.clear(false); 1105 this._viewedElement = element; 1106 1107 this.clearPseudoClassPanel(); 1108 this.refreshAddRuleButtonState(); 1109 1110 if (!this._viewedElement) { 1111 this._stopSelectingElement(); 1112 this._clearRules(); 1113 this._showEmpty(); 1114 this.refreshPseudoClassPanel(); 1115 if (this.pageStyle) { 1116 this.pageStyle.off("stylesheet-updated", this.refreshPanel); 1117 this.pageStyle = null; 1118 } 1119 return Promise.resolve(undefined); 1120 } 1121 1122 const isProfilerActive = Services.profiler?.IsActive(); 1123 const startTime = isProfilerActive ? ChromeUtils.now() : null; 1124 1125 this.pageStyle = element.inspectorFront.pageStyle; 1126 this.pageStyle.on("stylesheet-updated", this.refreshPanel); 1127 1128 // To figure out how shorthand properties are interpreted by the 1129 // engine, we will set properties on a dummy element and observe 1130 // how their .style attribute reflects them as computed values. 1131 const dummyElementPromise = Promise.resolve(this.styleDocument) 1132 .then(document => { 1133 // ::before and ::after do not have a namespaceURI 1134 const namespaceURI = 1135 this.element.namespaceURI || document.documentElement.namespaceURI; 1136 this._dummyElement = document.createElementNS( 1137 namespaceURI, 1138 this.element.tagName 1139 ); 1140 }) 1141 .catch(promiseWarn); 1142 1143 const elementStyle = new ElementStyle( 1144 element, 1145 this, 1146 this.store, 1147 this.pageStyle, 1148 this.showUserAgentStyles 1149 ); 1150 this._elementStyle = elementStyle; 1151 1152 this._startSelectingElement(); 1153 1154 return dummyElementPromise 1155 .then(() => { 1156 if (this._elementStyle === elementStyle) { 1157 return this._populate(); 1158 } 1159 return undefined; 1160 }) 1161 .then(() => { 1162 if (this._elementStyle === elementStyle) { 1163 if (!refresh) { 1164 this.element.scrollTop = 0; 1165 } 1166 this._stopSelectingElement(); 1167 this._elementStyle.onChanged = () => { 1168 this._changed(); 1169 }; 1170 } 1171 if (isProfilerActive && this._elementStyle.rules) { 1172 let declarations = 0; 1173 for (const rule of this._elementStyle.rules) { 1174 declarations += rule.textProps.length; 1175 } 1176 ChromeUtils.addProfilerMarker( 1177 "DevTools:CssRuleView.selectElement", 1178 startTime, 1179 `${declarations} CSS declarations in ${this._elementStyle.rules.length} rules` 1180 ); 1181 } 1182 }) 1183 .catch(e => { 1184 if (this._elementStyle === elementStyle) { 1185 this._stopSelectingElement(); 1186 this._clearRules(); 1187 } 1188 console.error(e); 1189 }); 1190 } 1191 1192 /** 1193 * Update the rules for the currently highlighted element. 1194 */ 1195 refreshPanel() { 1196 // Ignore refreshes when the panel is hidden, or during editing or when no element is selected. 1197 if (!this.isPanelVisible() || this.isEditing || !this._elementStyle) { 1198 return Promise.resolve(undefined); 1199 } 1200 1201 // Repopulate the element style once the current modifications are done. 1202 const promises = []; 1203 for (const rule of this._elementStyle.rules) { 1204 if (rule._applyingModifications) { 1205 promises.push(rule._applyingModifications); 1206 } 1207 } 1208 1209 return Promise.all(promises).then(() => { 1210 return this._populate(); 1211 }); 1212 } 1213 1214 /** 1215 * Clear the pseudo class options panel by removing the checked and disabled 1216 * attributes for each checkbox. 1217 */ 1218 clearPseudoClassPanel() { 1219 this.pseudoClassCheckboxes.forEach(checkbox => { 1220 checkbox.checked = false; 1221 checkbox.disabled = false; 1222 }); 1223 } 1224 1225 /** 1226 * For each item in PSEUDO_CLASSES, create a checkbox input element for toggling a 1227 * pseudo-class on the selected element and append it to the pseudo-class panel. 1228 * 1229 * Returns an array with the checkbox input elements for pseudo-classes. 1230 * 1231 * @return {Array} 1232 */ 1233 _createPseudoClassCheckboxes() { 1234 const doc = this.styleDocument; 1235 const fragment = doc.createDocumentFragment(); 1236 1237 for (const pseudo of PSEUDO_CLASSES) { 1238 const label = doc.createElement("label"); 1239 const checkbox = doc.createElement("input"); 1240 checkbox.setAttribute("tabindex", "-1"); 1241 checkbox.setAttribute("type", "checkbox"); 1242 checkbox.setAttribute("value", pseudo); 1243 1244 label.append(checkbox, pseudo); 1245 fragment.append(label); 1246 } 1247 1248 this.pseudoClassPanel.append(fragment); 1249 return Array.from( 1250 this.pseudoClassPanel.querySelectorAll("input[type=checkbox]") 1251 ); 1252 } 1253 1254 /** 1255 * Update the pseudo class options for the currently highlighted element. 1256 */ 1257 refreshPseudoClassPanel() { 1258 if ( 1259 !this._elementStyle || 1260 !this.inspector.canTogglePseudoClassForSelectedNode() 1261 ) { 1262 this.pseudoClassCheckboxes.forEach(checkbox => { 1263 checkbox.disabled = true; 1264 }); 1265 return; 1266 } 1267 1268 const pseudoClassLocks = this._elementStyle.element.pseudoClassLocks; 1269 this.pseudoClassCheckboxes.forEach(checkbox => { 1270 checkbox.disabled = false; 1271 checkbox.checked = pseudoClassLocks.includes(checkbox.value); 1272 }); 1273 } 1274 1275 _populate() { 1276 const elementStyle = this._elementStyle; 1277 return this._elementStyle 1278 .populate() 1279 .then(() => { 1280 if (this._elementStyle !== elementStyle || this.isDestroyed) { 1281 return null; 1282 } 1283 1284 this._clearRules(); 1285 const onEditorsReady = this._createEditors(); 1286 this.refreshPseudoClassPanel(); 1287 1288 // Notify anyone that cares that we refreshed. 1289 return onEditorsReady.then(() => { 1290 this.emit("ruleview-refreshed"); 1291 }, console.error); 1292 }) 1293 .catch(promiseWarn); 1294 } 1295 1296 /** 1297 * Show the user that the rule view has no node selected. 1298 */ 1299 _showEmpty() { 1300 if (this.styleDocument.getElementById("ruleview-no-results")) { 1301 return; 1302 } 1303 1304 createChild(this.element, "div", { 1305 id: "ruleview-no-results", 1306 class: "devtools-sidepanel-no-result", 1307 textContent: l10n("rule.empty"), 1308 }); 1309 } 1310 1311 /** 1312 * Clear the rules. 1313 */ 1314 _clearRules() { 1315 this.element.innerHTML = ""; 1316 } 1317 1318 /** 1319 * Clear the rule view. 1320 */ 1321 clear(clearDom = true) { 1322 if (clearDom) { 1323 this._clearRules(); 1324 } 1325 this._viewedElement = null; 1326 1327 if (this._elementStyle) { 1328 this._elementStyle.destroy(); 1329 this._elementStyle = null; 1330 } 1331 1332 if (this.pageStyle) { 1333 this.pageStyle.off("stylesheet-updated", this.refreshPanel); 1334 this.pageStyle = null; 1335 } 1336 } 1337 1338 /** 1339 * Called when the user has made changes to the ElementStyle. 1340 * Emits an event that clients can listen to. 1341 */ 1342 _changed() { 1343 this.emit("ruleview-changed"); 1344 } 1345 1346 /** 1347 * Text for header that shows above rules for this element 1348 */ 1349 get selectedElementLabel() { 1350 if (this._selectedElementLabel) { 1351 return this._selectedElementLabel; 1352 } 1353 this._selectedElementLabel = l10n("rule.selectedElement"); 1354 return this._selectedElementLabel; 1355 } 1356 1357 /** 1358 * Text for header that shows above rules for pseudo elements 1359 */ 1360 get pseudoElementLabel() { 1361 if (this._pseudoElementLabel) { 1362 return this._pseudoElementLabel; 1363 } 1364 this._pseudoElementLabel = l10n("rule.pseudoElement"); 1365 return this._pseudoElementLabel; 1366 } 1367 1368 get showPseudoElements() { 1369 if (this._showPseudoElements === undefined) { 1370 this._showPseudoElements = Services.prefs.getBoolPref( 1371 "devtools.inspector.show_pseudo_elements" 1372 ); 1373 } 1374 return this._showPseudoElements; 1375 } 1376 1377 /** 1378 * Creates an expandable container in the rule view 1379 * 1380 * @param {string} label 1381 * The label for the container header 1382 * @param {string} containerId 1383 * The id that will be set on the container 1384 * @param {boolean} isPseudo 1385 * Whether or not the container will hold pseudo element rules 1386 * @return {DOMNode} The container element 1387 */ 1388 createExpandableContainer(label, containerId, isPseudo = false) { 1389 const header = this.styleDocument.createElementNS(HTML_NS, "div"); 1390 header.classList.add( 1391 RULE_VIEW_HEADER_CLASSNAME, 1392 "ruleview-expandable-header" 1393 ); 1394 header.setAttribute("role", "heading"); 1395 1396 const toggleButton = this.styleDocument.createElementNS(HTML_NS, "button"); 1397 toggleButton.setAttribute( 1398 "title", 1399 l10n("rule.expandableContainerToggleButton.title") 1400 ); 1401 toggleButton.setAttribute("aria-expanded", "true"); 1402 toggleButton.setAttribute("aria-controls", containerId); 1403 1404 const twisty = this.styleDocument.createElementNS(HTML_NS, "span"); 1405 twisty.className = "ruleview-expander theme-twisty"; 1406 1407 toggleButton.append(twisty, this.styleDocument.createTextNode(label)); 1408 header.append(toggleButton); 1409 1410 const container = this.styleDocument.createElementNS(HTML_NS, "div"); 1411 container.id = containerId; 1412 container.classList.add("ruleview-expandable-container"); 1413 container.hidden = false; 1414 1415 this.element.append(header, container); 1416 1417 toggleButton.addEventListener("click", () => { 1418 this._toggleContainerVisibility( 1419 toggleButton, 1420 container, 1421 isPseudo, 1422 !this.showPseudoElements 1423 ); 1424 }); 1425 1426 if (isPseudo) { 1427 this._toggleContainerVisibility( 1428 toggleButton, 1429 container, 1430 isPseudo, 1431 this.showPseudoElements 1432 ); 1433 } 1434 1435 return container; 1436 } 1437 1438 /** 1439 * Create the `@property` expandable container 1440 * 1441 * @returns {Element} 1442 */ 1443 createRegisteredPropertiesExpandableContainer() { 1444 const el = this.createExpandableContainer( 1445 "@property", 1446 REGISTERED_PROPERTIES_CONTAINER_ID 1447 ); 1448 el.classList.add("registered-properties"); 1449 return el; 1450 } 1451 1452 /** 1453 * Return the RegisteredPropertyEditor element for a given property name 1454 * 1455 * @param {string} registeredPropertyName 1456 * @returns {Element|null} 1457 */ 1458 getRegisteredPropertyElement(registeredPropertyName) { 1459 return this.styleDocument.querySelector( 1460 `#${REGISTERED_PROPERTIES_CONTAINER_ID} [data-name="${registeredPropertyName}"]` 1461 ); 1462 } 1463 1464 /** 1465 * Toggle the visibility of an expandable container 1466 * 1467 * @param {DOMNode} twisty 1468 * Clickable toggle DOM Node 1469 * @param {DOMNode} container 1470 * Expandable container DOM Node 1471 * @param {boolean} isPseudo 1472 * Whether or not the container will hold pseudo element rules 1473 * @param {boolean} showPseudo 1474 * Whether or not pseudo element rules should be displayed 1475 */ 1476 _toggleContainerVisibility(toggleButton, container, isPseudo, showPseudo) { 1477 let isOpen = toggleButton.getAttribute("aria-expanded") === "true"; 1478 1479 if (isPseudo) { 1480 this._showPseudoElements = !!showPseudo; 1481 1482 Services.prefs.setBoolPref( 1483 "devtools.inspector.show_pseudo_elements", 1484 this.showPseudoElements 1485 ); 1486 1487 container.hidden = !this.showPseudoElements; 1488 isOpen = !this.showPseudoElements; 1489 } else { 1490 container.hidden = !container.hidden; 1491 } 1492 1493 toggleButton.setAttribute("aria-expanded", !isOpen); 1494 } 1495 1496 /** 1497 * Creates editor UI for each of the rules in _elementStyle. 1498 */ 1499 // eslint-disable-next-line complexity 1500 _createEditors() { 1501 // Run through the current list of rules, attaching 1502 // their editors in order. Create editors if needed. 1503 let lastInherited = null; 1504 let lastinheritedSectionLabel = ""; 1505 let seenNormalElement = false; 1506 let seenSearchTerm = false; 1507 const containers = new Map(); 1508 1509 if (!this._elementStyle.rules) { 1510 return Promise.resolve(); 1511 } 1512 1513 const editorReadyPromises = []; 1514 for (const rule of this._elementStyle.rules) { 1515 if (rule.domRule.system) { 1516 continue; 1517 } 1518 1519 // Initialize rule editor 1520 if (!rule.editor) { 1521 const ruleActorID = rule.domRule.actorID; 1522 rule.editor = new RuleEditor(this, rule, { 1523 elementsWithPendingClicks: this._elementsWithPendingClicks, 1524 onShowUnusedCustomCssProperties: () => { 1525 this.store.expandedUnusedCustomCssPropertiesRuleActorIds.add( 1526 ruleActorID 1527 ); 1528 }, 1529 shouldHideUnusedCustomCssProperties: 1530 !this.store.expandedUnusedCustomCssPropertiesRuleActorIds.has( 1531 ruleActorID 1532 ), 1533 }); 1534 editorReadyPromises.push(rule.editor.once("source-link-updated")); 1535 } 1536 1537 // Filter the rules and highlight any matches if there is a search input 1538 if (this.searchValue && this.searchData) { 1539 if (this.highlightRule(rule)) { 1540 seenSearchTerm = true; 1541 } else if (rule.domRule.type !== ELEMENT_STYLE) { 1542 continue; 1543 } 1544 } 1545 1546 const isNonInheritedPseudo = !!rule.pseudoElement && !rule.inherited; 1547 1548 // Only print header for this element if there are pseudo elements 1549 if ( 1550 containers.has(PSEUDO_ELEMENTS_CONTAINER_ID) && 1551 !seenNormalElement && 1552 !rule.pseudoElement 1553 ) { 1554 seenNormalElement = true; 1555 const div = this.styleDocument.createElementNS(HTML_NS, "div"); 1556 div.className = RULE_VIEW_HEADER_CLASSNAME; 1557 div.setAttribute("role", "heading"); 1558 div.textContent = this.selectedElementLabel; 1559 this.element.appendChild(div); 1560 } 1561 1562 const { inherited, inheritedSectionLabel } = rule; 1563 // We need to check both `inherited` (a NodeFront) and `inheritedSectionLabel` (string), 1564 // as element-backed pseudo element rules (e.g. `::details-content`) can have the same 1565 // `inherited` property as a regular rule (e.g. on `<details>`), but the element is 1566 // to be considered as a child of the binding element. 1567 // e.g. we want to have 1568 // This element 1569 // Inherited by details::details-content 1570 // Inherited by details 1571 if ( 1572 inherited && 1573 (inherited !== lastInherited || 1574 inheritedSectionLabel !== lastinheritedSectionLabel) 1575 ) { 1576 const div = this.styleDocument.createElementNS(HTML_NS, "div"); 1577 div.classList.add(RULE_VIEW_HEADER_CLASSNAME); 1578 div.setAttribute("role", "heading"); 1579 div.setAttribute("aria-level", "3"); 1580 div.textContent = rule.inheritedSectionLabel; 1581 lastInherited = inherited; 1582 lastinheritedSectionLabel = inheritedSectionLabel; 1583 this.element.appendChild(div); 1584 } 1585 1586 const keyframes = rule.keyframes; 1587 1588 let containerKey = null; 1589 1590 // Don't display inherited pseudo element rules (e.g. ::details-content) inside 1591 // the pseudo element container 1592 if (isNonInheritedPseudo) { 1593 containerKey = PSEUDO_ELEMENTS_CONTAINER_ID; 1594 if (!containers.has(containerKey)) { 1595 containers.set( 1596 containerKey, 1597 this.createExpandableContainer( 1598 this.pseudoElementLabel, 1599 containerKey, 1600 true 1601 ) 1602 ); 1603 } 1604 } else if (keyframes) { 1605 containerKey = keyframes; 1606 if (!containers.has(containerKey)) { 1607 containers.set( 1608 containerKey, 1609 this.createExpandableContainer( 1610 rule.keyframesName, 1611 `keyframes-container-${keyframes.name}` 1612 ) 1613 ); 1614 } 1615 } else if (rule.domRule.className === "CSSPositionTryRule") { 1616 containerKey = POSITION_TRY_CONTAINER_ID; 1617 if (!containers.has(containerKey)) { 1618 containers.set( 1619 containerKey, 1620 this.createExpandableContainer( 1621 `@position-try`, 1622 `position-try-container` 1623 ) 1624 ); 1625 } 1626 } 1627 1628 rule.editor.element.setAttribute("role", "article"); 1629 const container = containers.get(containerKey); 1630 if (container) { 1631 container.appendChild(rule.editor.element); 1632 } else { 1633 this.element.appendChild(rule.editor.element); 1634 } 1635 1636 // Automatically select the selector input when we are adding a user-added rule 1637 if (this._focusNextUserAddedRule && rule.domRule.userAdded) { 1638 this._focusNextUserAddedRule = null; 1639 rule.editor.selectorText.click(); 1640 this.emitForTests("new-rule-added", rule); 1641 } 1642 } 1643 1644 const targetRegisteredProperties = 1645 this.getRegisteredPropertiesForSelectedNodeTarget(); 1646 if (targetRegisteredProperties?.size) { 1647 const registeredPropertiesContainer = 1648 this.createRegisteredPropertiesExpandableContainer(); 1649 1650 // Sort properties by their name, as we want to display them in alphabetical order 1651 const propertyDefinitions = Array.from( 1652 targetRegisteredProperties.values() 1653 ).sort((a, b) => (a.name < b.name ? -1 : 1)); 1654 for (const propertyDefinition of propertyDefinitions) { 1655 const registeredPropertyEditor = new RegisteredPropertyEditor( 1656 this, 1657 propertyDefinition 1658 ); 1659 1660 registeredPropertiesContainer.appendChild( 1661 registeredPropertyEditor.element 1662 ); 1663 } 1664 } 1665 1666 const searchBox = this.searchField.parentNode; 1667 searchBox.classList.toggle( 1668 "devtools-searchbox-no-match", 1669 this.searchValue && !seenSearchTerm 1670 ); 1671 1672 return Promise.all(editorReadyPromises); 1673 } 1674 1675 /** 1676 * Highlight rules that matches the filter search value and returns a 1677 * boolean indicating whether or not rules were highlighted. 1678 * 1679 * @param {Rule} rule 1680 * The rule object we're highlighting if its rule selectors or 1681 * property values match the search value. 1682 * @return {boolean} true if the rule was highlighted, false otherwise. 1683 */ 1684 highlightRule(rule) { 1685 const isRuleSelectorHighlighted = this._highlightRuleSelector(rule); 1686 const isStyleSheetHighlighted = this._highlightStyleSheet(rule); 1687 const isAncestorRulesHighlighted = this._highlightAncestorRules(rule); 1688 let isHighlighted = 1689 isRuleSelectorHighlighted || 1690 isStyleSheetHighlighted || 1691 isAncestorRulesHighlighted; 1692 1693 // Highlight search matches in the rule properties 1694 for (const textProp of rule.textProps) { 1695 if (!textProp.invisible && this._highlightProperty(textProp)) { 1696 isHighlighted = true; 1697 } 1698 } 1699 1700 return isHighlighted; 1701 } 1702 1703 /** 1704 * Highlights the rule selector that matches the filter search value and 1705 * returns a boolean indicating whether or not the selector was highlighted. 1706 * 1707 * @param {Rule} rule 1708 * The Rule object. 1709 * @return {boolean} true if the rule selector was highlighted, 1710 * false otherwise. 1711 */ 1712 _highlightRuleSelector(rule) { 1713 let isSelectorHighlighted = false; 1714 1715 let selectorNodes = [...rule.editor.selectorText.childNodes]; 1716 if ( 1717 rule.domRule.type === CSSRule.KEYFRAME_RULE || 1718 rule.domRule.className === "CSSPositionTryRule" 1719 ) { 1720 selectorNodes = [rule.editor.selectorText]; 1721 } else if (rule.domRule.type === ELEMENT_STYLE) { 1722 selectorNodes = []; 1723 } 1724 1725 // Highlight search matches in the rule selectors 1726 for (const selectorNode of selectorNodes) { 1727 const selector = selectorNode.textContent.toLowerCase(); 1728 if ( 1729 (this.searchData.strictSearchAllValues && 1730 selector === this.searchData.strictSearchValue) || 1731 (!this.searchData.strictSearchAllValues && 1732 selector.includes(this.searchValue)) 1733 ) { 1734 selectorNode.classList.add("ruleview-highlight"); 1735 isSelectorHighlighted = true; 1736 } 1737 } 1738 1739 return isSelectorHighlighted; 1740 } 1741 1742 /** 1743 * Highlights the ancestor rules data (@media / @layer) that matches the filter search 1744 * value and returns a boolean indicating whether or not element was highlighted. 1745 * 1746 * @return {boolean} true if the element was highlighted, false otherwise. 1747 */ 1748 _highlightAncestorRules(rule) { 1749 const element = rule.editor.ancestorDataEl; 1750 if (!element) { 1751 return false; 1752 } 1753 1754 const ancestorSelectors = element.querySelectorAll( 1755 ".ruleview-rule-ancestor-selectorcontainer" 1756 ); 1757 1758 let isHighlighted = false; 1759 for (const child of ancestorSelectors) { 1760 const dataText = child.innerText.toLowerCase(); 1761 const matches = this.searchData.strictSearchValue 1762 ? dataText === this.searchData.strictSearchValue 1763 : dataText.includes(this.searchValue); 1764 if (matches) { 1765 isHighlighted = true; 1766 child.classList.add("ruleview-highlight"); 1767 } 1768 } 1769 1770 return isHighlighted; 1771 } 1772 1773 /** 1774 * Highlights the stylesheet source that matches the filter search value and 1775 * returns a boolean indicating whether or not the stylesheet source was 1776 * highlighted. 1777 * 1778 * @return {boolean} true if the stylesheet source was highlighted, false 1779 * otherwise. 1780 */ 1781 _highlightStyleSheet(rule) { 1782 const styleSheetSource = rule.title.toLowerCase(); 1783 const isStyleSheetHighlighted = this.searchData.strictSearchValue 1784 ? styleSheetSource === this.searchData.strictSearchValue 1785 : styleSheetSource.includes(this.searchValue); 1786 1787 if (isStyleSheetHighlighted && rule.editor.source) { 1788 rule.editor.source.classList.add("ruleview-highlight"); 1789 } 1790 1791 return isStyleSheetHighlighted; 1792 } 1793 1794 /** 1795 * Highlights the rule properties and computed properties that match the 1796 * filter search value and returns a boolean indicating whether or not the 1797 * property or computed property was highlighted. 1798 * 1799 * @param {TextProperty} textProperty 1800 * The rule property. 1801 * @returns {boolean} true if the property or computed property was 1802 * highlighted, false otherwise. 1803 */ 1804 _highlightProperty(textProperty) { 1805 const isPropertyHighlighted = this._highlightRuleProperty(textProperty); 1806 const isComputedHighlighted = this._highlightComputedProperty(textProperty); 1807 1808 // Expand the computed list if a computed property is highlighted and the 1809 // property rule is not highlighted 1810 if ( 1811 !isPropertyHighlighted && 1812 isComputedHighlighted && 1813 !textProperty.editor.computed.hasAttribute("user-open") 1814 ) { 1815 textProperty.editor.expandForFilter(); 1816 } 1817 1818 return isPropertyHighlighted || isComputedHighlighted; 1819 } 1820 1821 /** 1822 * Called when TextPropertyEditor is updated and updates the rule property 1823 * highlight. 1824 * 1825 * @param {TextPropertyEditor} editor 1826 * The rule property TextPropertyEditor object. 1827 */ 1828 _updatePropertyHighlight(editor) { 1829 if (!this.searchValue || !this.searchData) { 1830 return; 1831 } 1832 1833 this._clearHighlight(editor.element); 1834 1835 if (this._highlightProperty(editor.prop)) { 1836 this.searchField.classList.remove("devtools-style-searchbox-no-match"); 1837 } 1838 } 1839 1840 /** 1841 * Highlights the rule property that matches the filter search value 1842 * and returns a boolean indicating whether or not the property was 1843 * highlighted. 1844 * 1845 * @param {TextProperty} textProperty 1846 * The rule property object. 1847 * @returns {boolean} true if the rule property was highlighted, 1848 * false otherwise. 1849 */ 1850 _highlightRuleProperty(textProperty) { 1851 const propertyName = textProperty.name.toLowerCase(); 1852 // Get the actual property value displayed in the rule view if we have an editor for 1853 // it (that might not be the case for unused CSS custom properties). 1854 const propertyValue = textProperty.editor 1855 ? textProperty.editor.valueSpan.textContent.toLowerCase() 1856 : textProperty.value.toLowerCase(); 1857 1858 return this._highlightMatches({ 1859 element: textProperty.editor?.container, 1860 propertyName, 1861 propertyValue, 1862 textProperty, 1863 }); 1864 } 1865 1866 /** 1867 * Highlights the computed property that matches the filter search value and 1868 * returns a boolean indicating whether or not the computed property was 1869 * highlighted. 1870 * 1871 * @param {TextProperty} textProperty 1872 * The rule property object. 1873 * @returns {boolean} true if the computed property was highlighted, false 1874 * otherwise. 1875 */ 1876 _highlightComputedProperty(textProperty) { 1877 if (!textProperty.editor) { 1878 return false; 1879 } 1880 1881 let isComputedHighlighted = false; 1882 1883 // Highlight search matches in the computed list of properties 1884 textProperty.editor.populateComputed(); 1885 for (const computed of textProperty.computed) { 1886 if (computed.element) { 1887 // Get the actual property value displayed in the computed list 1888 const computedName = computed.name.toLowerCase(); 1889 const computedValue = computed.parsedValue.toLowerCase(); 1890 1891 isComputedHighlighted = this._highlightMatches({ 1892 element: computed.element, 1893 propertyName: computedName, 1894 propertyValue: computedValue, 1895 textProperty, 1896 }) 1897 ? true 1898 : isComputedHighlighted; 1899 } 1900 } 1901 1902 return isComputedHighlighted; 1903 } 1904 1905 /** 1906 * Helper function for highlightRules that carries out highlighting the given 1907 * element if the search terms match the property, and returns a boolean 1908 * indicating whether or not the search terms match. 1909 * 1910 * @param {object} options 1911 * @param {DOMNode} options.element 1912 * The node to highlight if search terms match 1913 * @param {string} options.propertyName 1914 * The property name of a rule 1915 * @param {string} options.propertyValue 1916 * The property value of a rule 1917 * @param {TextProperty} options.textProperty 1918 * The text property that we may highlight. It's helpful in cases we don't have 1919 * an element yet (e.g. if the property is a hidden unused variable) 1920 * @return {boolean} true if the given search terms match the property, false 1921 * otherwise. 1922 */ 1923 _highlightMatches({ element, propertyName, propertyValue, textProperty }) { 1924 const { 1925 searchPropertyName, 1926 searchPropertyValue, 1927 searchPropertyMatch, 1928 strictSearchPropertyName, 1929 strictSearchPropertyValue, 1930 strictSearchAllValues, 1931 } = this.searchData; 1932 let matches = false; 1933 1934 // If the inputted search value matches a property line like 1935 // `font-family: arial`, then check to make sure the name and value match. 1936 // Otherwise, just compare the inputted search string directly against the 1937 // name and value of the rule property. 1938 const hasNameAndValue = 1939 searchPropertyMatch && searchPropertyName && searchPropertyValue; 1940 const isMatch = (value, query, isStrict) => { 1941 return isStrict ? value === query : query && value.includes(query); 1942 }; 1943 1944 if (hasNameAndValue) { 1945 matches = 1946 isMatch(propertyName, searchPropertyName, strictSearchPropertyName) && 1947 isMatch(propertyValue, searchPropertyValue, strictSearchPropertyValue); 1948 } else { 1949 matches = 1950 isMatch( 1951 propertyName, 1952 searchPropertyName, 1953 strictSearchPropertyName || strictSearchAllValues 1954 ) || 1955 isMatch( 1956 propertyValue, 1957 searchPropertyValue, 1958 strictSearchPropertyValue || strictSearchAllValues 1959 ); 1960 } 1961 1962 if (!matches) { 1963 return false; 1964 } 1965 1966 // We might not have an element when the prop is an unused custom css property. 1967 if (!element && textProperty?.isUnusedVariable) { 1968 const editor = 1969 textProperty.rule.editor.showUnusedCssVariable(textProperty); 1970 1971 // The editor couldn't be created, bail (shouldn't happen) 1972 if (!editor) { 1973 return false; 1974 } 1975 1976 element = editor.container; 1977 } 1978 1979 element.classList.add("ruleview-highlight"); 1980 1981 return true; 1982 } 1983 1984 /** 1985 * Clear all search filter highlights in the panel, and close the computed 1986 * list if toggled opened 1987 */ 1988 _clearHighlight(element) { 1989 for (const el of element.querySelectorAll(".ruleview-highlight")) { 1990 el.classList.remove("ruleview-highlight"); 1991 } 1992 1993 for (const computed of element.querySelectorAll( 1994 ".ruleview-computedlist[filter-open]" 1995 )) { 1996 computed.parentNode._textPropertyEditor.collapseForFilter(); 1997 } 1998 } 1999 2000 /** 2001 * Called when the pseudo class panel button is clicked and toggles 2002 * the display of the pseudo class panel. 2003 */ 2004 _onTogglePseudoClassPanel() { 2005 if (this.pseudoClassPanel.hidden) { 2006 this.showPseudoClassPanel(); 2007 } else { 2008 this.hidePseudoClassPanel(); 2009 } 2010 } 2011 2012 showPseudoClassPanel() { 2013 this.hideClassPanel(); 2014 2015 this.pseudoClassToggle.setAttribute("aria-pressed", "true"); 2016 this.pseudoClassCheckboxes.forEach(checkbox => { 2017 checkbox.setAttribute("tabindex", "0"); 2018 }); 2019 this.pseudoClassPanel.hidden = false; 2020 } 2021 2022 hidePseudoClassPanel() { 2023 this.pseudoClassToggle.setAttribute("aria-pressed", "false"); 2024 this.pseudoClassCheckboxes.forEach(checkbox => { 2025 checkbox.setAttribute("tabindex", "-1"); 2026 }); 2027 this.pseudoClassPanel.hidden = true; 2028 } 2029 2030 /** 2031 * Called when a pseudo class checkbox is clicked and toggles 2032 * the pseudo class for the current selected element. 2033 */ 2034 _onTogglePseudoClass(event) { 2035 const target = event.target; 2036 this.inspector.togglePseudoClass(target.value); 2037 } 2038 2039 /** 2040 * Called when the class panel button is clicked and toggles the display of the class 2041 * panel. 2042 */ 2043 _onToggleClassPanel() { 2044 if (this.classPanel.hidden) { 2045 this.showClassPanel(); 2046 } else { 2047 this.hideClassPanel(); 2048 } 2049 } 2050 2051 showClassPanel() { 2052 this.hidePseudoClassPanel(); 2053 2054 this.classToggle.setAttribute("aria-pressed", "true"); 2055 this.classPanel.hidden = false; 2056 2057 this.classListPreviewer.focusAddClassField(); 2058 } 2059 2060 hideClassPanel() { 2061 this.classToggle.setAttribute("aria-pressed", "false"); 2062 this.classPanel.hidden = true; 2063 } 2064 2065 /** 2066 * Handle the keypress event in the rule view. 2067 */ 2068 _onShortcut(name, event) { 2069 if (!event.target.closest("#sidebar-panel-ruleview")) { 2070 return; 2071 } 2072 2073 if (name === "CmdOrCtrl+F") { 2074 this.searchField.focus(); 2075 event.preventDefault(); 2076 } else if ( 2077 (name === "Return" || name === "Space") && 2078 this.element.classList.contains("non-interactive") 2079 ) { 2080 event.preventDefault(); 2081 } else if ( 2082 name === "Escape" && 2083 event.target === this.searchField && 2084 this._onClearSearch() 2085 ) { 2086 // Handle the search box's keypress event. If the escape key is pressed, 2087 // clear the search box field. 2088 event.preventDefault(); 2089 event.stopPropagation(); 2090 } 2091 } 2092 2093 async _onToggleLightColorSchemeSimulation() { 2094 const shouldSimulateLightScheme = 2095 this.colorSchemeLightSimulationButton.getAttribute("aria-pressed") !== 2096 "true"; 2097 2098 this.colorSchemeLightSimulationButton.setAttribute( 2099 "aria-pressed", 2100 shouldSimulateLightScheme 2101 ); 2102 2103 this.colorSchemeDarkSimulationButton.setAttribute("aria-pressed", "false"); 2104 2105 await this.inspector.commands.targetConfigurationCommand.updateConfiguration( 2106 { 2107 colorSchemeSimulation: shouldSimulateLightScheme ? "light" : null, 2108 } 2109 ); 2110 // Refresh the current element's rules in the panel. 2111 this.refreshPanel(); 2112 } 2113 2114 async _onToggleDarkColorSchemeSimulation() { 2115 const shouldSimulateDarkScheme = 2116 this.colorSchemeDarkSimulationButton.getAttribute("aria-pressed") !== 2117 "true"; 2118 2119 this.colorSchemeDarkSimulationButton.setAttribute( 2120 "aria-pressed", 2121 shouldSimulateDarkScheme 2122 ); 2123 2124 this.colorSchemeLightSimulationButton.setAttribute("aria-pressed", "false"); 2125 2126 await this.inspector.commands.targetConfigurationCommand.updateConfiguration( 2127 { 2128 colorSchemeSimulation: shouldSimulateDarkScheme ? "dark" : null, 2129 } 2130 ); 2131 // Refresh the current element's rules in the panel. 2132 this.refreshPanel(); 2133 } 2134 2135 async _onTogglePrintSimulation() { 2136 const enabled = 2137 this.printSimulationButton.getAttribute("aria-pressed") !== "true"; 2138 this.printSimulationButton.setAttribute("aria-pressed", enabled); 2139 await this.inspector.commands.targetConfigurationCommand.updateConfiguration( 2140 { 2141 printSimulationEnabled: enabled, 2142 } 2143 ); 2144 // Refresh the current element's rules in the panel. 2145 this.refreshPanel(); 2146 } 2147 2148 /** 2149 * Temporarily flash the given element. 2150 * 2151 * @param {Element} element 2152 * The element. 2153 * @returns {Promise} Promise that resolves after the element was flashed-out 2154 */ 2155 _flashElement(element) { 2156 flashElementOn(element, { 2157 backgroundClass: "theme-bg-contrast", 2158 }); 2159 2160 if (this._flashMutationCallback) { 2161 this._flashMutationCallback(); 2162 } 2163 2164 return new Promise(resolve => { 2165 this._flashMutationCallback = () => { 2166 flashElementOff(element, { 2167 backgroundClass: "theme-bg-contrast", 2168 }); 2169 this._flashMutationCallback = null; 2170 resolve(); 2171 }; 2172 2173 setTimeout(this._flashMutationCallback, PROPERTY_FLASHING_DURATION); 2174 }); 2175 } 2176 2177 /** 2178 * Scrolls to the top of either the rule or declaration. The view will try to scroll to 2179 * the rule if both can fit in the viewport. If not, then scroll to the declaration. 2180 * 2181 * @param {Element} rule 2182 * The rule to scroll to. 2183 * @param {Element|null} declaration 2184 * Optional. The declaration to scroll to. 2185 * @param {string} scrollBehavior 2186 * Optional. The transition animation when scrolling. If prefers-reduced-motion 2187 * system pref is set, then the scroll behavior will be overridden to "auto". 2188 */ 2189 _scrollToElement(rule, declaration, scrollBehavior = "smooth") { 2190 let elementToScrollTo = rule; 2191 2192 if (declaration) { 2193 const { offsetTop, offsetHeight } = declaration; 2194 // Get the distance between both the rule and declaration. If the distance is 2195 // greater than the height of the rule view, then only scroll to the declaration. 2196 const distance = offsetTop + offsetHeight - rule.offsetTop; 2197 2198 if (this.element.parentNode.offsetHeight <= distance) { 2199 elementToScrollTo = declaration; 2200 } 2201 } 2202 2203 // Ensure that smooth scrolling is disabled when the user prefers reduced motion. 2204 const win = elementToScrollTo.ownerGlobal; 2205 const reducedMotion = win.matchMedia("(prefers-reduced-motion)").matches; 2206 scrollBehavior = reducedMotion ? "auto" : scrollBehavior; 2207 2208 elementToScrollTo.scrollIntoView({ behavior: scrollBehavior }); 2209 } 2210 2211 /** 2212 * Toggles the visibility of the pseudo element rule's container. 2213 */ 2214 _togglePseudoElementRuleContainer() { 2215 const container = this.styleDocument.getElementById( 2216 PSEUDO_ELEMENTS_CONTAINER_ID 2217 ); 2218 const toggle = this.styleDocument.querySelector( 2219 `[aria-controls="${PSEUDO_ELEMENTS_CONTAINER_ID}"]` 2220 ); 2221 this._toggleContainerVisibility(toggle, container, true, true); 2222 } 2223 2224 /** 2225 * Finds the specified TextProperty name in the rule view. If found, scroll to and 2226 * flash the TextProperty. 2227 * 2228 * @param {string} name 2229 * The property name to scroll to and highlight. 2230 * @param {object} options 2231 * @param {Function|undefined} options.ruleValidator 2232 * An optional function that can be used to filter out rules we shouldn't look 2233 * into to find the property name. The function is called with a Rule object, 2234 * and the rule will be skipped if the function returns a falsy value. 2235 * @return {boolean} true if the TextProperty name is found, and false otherwise. 2236 */ 2237 highlightProperty(name, { ruleValidator } = {}) { 2238 // First, let's clear any search we might have, as the property could be hidden 2239 this._onClearSearch(); 2240 2241 let scrollBehavior = "auto"; 2242 const hasRuleValidator = typeof ruleValidator === "function"; 2243 for (const rule of this.rules) { 2244 if (hasRuleValidator && !ruleValidator(rule)) { 2245 continue; 2246 } 2247 for (const textProp of rule.textProps) { 2248 if (textProp.overridden || textProp.invisible || !textProp.enabled) { 2249 continue; 2250 } 2251 2252 // First, search for a matching authored property. 2253 if (textProp.name === name) { 2254 // If using 2-Pane mode, then switch to the Rules tab first. 2255 if (!this.inspector.isThreePaneModeEnabled) { 2256 this.inspector.sidebar.select("ruleview"); 2257 } 2258 2259 // If the property is being applied by a pseudo element rule, expand the pseudo 2260 // element list container. 2261 if (rule.pseudoElement.length && !this.showPseudoElements) { 2262 // Set the scroll behavior to "auto" to avoid timing issues between toggling 2263 // the pseudo element container and scrolling smoothly to the rule. 2264 scrollBehavior = "auto"; 2265 this._togglePseudoElementRuleContainer(); 2266 } 2267 2268 // If we're jumping to an unused CSS variable, it might not be visible, so show 2269 // it here. 2270 if (!textProp.editor && textProp.isUnusedVariable) { 2271 textProp.rule.editor.showUnusedCssVariable(textProp); 2272 } 2273 2274 this._highlightElementInRule( 2275 rule, 2276 textProp.editor.element, 2277 scrollBehavior 2278 ); 2279 return true; 2280 } 2281 2282 // If there is no matching property, then look in computed properties. 2283 for (const computed of textProp.computed) { 2284 if (computed.overridden) { 2285 continue; 2286 } 2287 2288 if (computed.name === name) { 2289 if (!this.inspector.isThreePaneModeEnabled) { 2290 this.inspector.sidebar.select("ruleview"); 2291 } 2292 2293 if ( 2294 textProp.rule.pseudoElement.length && 2295 !this.showPseudoElements 2296 ) { 2297 scrollBehavior = "auto"; 2298 this._togglePseudoElementRuleContainer(); 2299 } 2300 2301 // Expand the computed list. 2302 textProp.editor.expandForFilter(); 2303 2304 this._highlightElementInRule( 2305 rule, 2306 computed.element, 2307 scrollBehavior 2308 ); 2309 2310 return true; 2311 } 2312 } 2313 } 2314 } 2315 // If the property is a CSS variable and we didn't find its declaration, it might 2316 // be a registered property 2317 if (this._maybeHighlightCssRegisteredProperty(name)) { 2318 return true; 2319 } 2320 2321 return false; 2322 } 2323 2324 /** 2325 * If the passed name matches a registered CSS property highlight it 2326 * 2327 * @param {string} name - The name of the registered property to highlight 2328 * @param {string} scrollBehavior 2329 * @returns {boolean} Returns true if `name` matched a registered property 2330 */ 2331 _maybeHighlightCssRegisteredProperty(name, scrollBehavior) { 2332 if (!name.startsWith("--")) { 2333 return false; 2334 } 2335 2336 // Get a potential @property section 2337 const propertyContainer = this.styleDocument.getElementById( 2338 REGISTERED_PROPERTIES_CONTAINER_ID 2339 ); 2340 if (!propertyContainer) { 2341 return false; 2342 } 2343 2344 const propertyEl = propertyContainer.querySelector(`[data-name="${name}"]`); 2345 if (!propertyEl) { 2346 return false; 2347 } 2348 2349 const toggle = this.styleDocument.querySelector( 2350 `[aria-controls="${REGISTERED_PROPERTIES_CONTAINER_ID}"]` 2351 ); 2352 if (toggle.ariaExpanded === "false") { 2353 this._toggleContainerVisibility(toggle, propertyContainer); 2354 } 2355 2356 this._highlightElementInRule(null, propertyEl, scrollBehavior); 2357 return true; 2358 } 2359 2360 /** 2361 * Highlight a given element in a rule editor 2362 * 2363 * @param {Rule} rule 2364 * @param {Element} element 2365 * @param {string} scrollBehavior 2366 */ 2367 _highlightElementInRule(rule, element, scrollBehavior) { 2368 if (rule) { 2369 this._scrollToElement(rule.editor.selectorText, element, scrollBehavior); 2370 } else { 2371 this._scrollToElement(element, null, scrollBehavior); 2372 } 2373 this._flashElement(element).then(() => 2374 this.emitForTests("element-highlighted", element) 2375 ); 2376 } 2377 2378 /** 2379 * Returns a Map (keyed by name) of the registered 2380 * properties for the currently selected node document. 2381 * 2382 * @returns Map<String, Object>|null 2383 */ 2384 getRegisteredPropertiesForSelectedNodeTarget() { 2385 return this.cssRegisteredPropertiesByTarget.get( 2386 this.inspector.selection.nodeFront.targetFront 2387 ); 2388 } 2389 } 2390 2391 class RuleViewTool { 2392 constructor(inspector, window) { 2393 this.inspector = inspector; 2394 this.document = window.document; 2395 2396 this.view = new CssRuleView(this.inspector, this.document); 2397 2398 this.refresh = this.refresh.bind(this); 2399 this.onDetachedFront = this.onDetachedFront.bind(this); 2400 this.onPanelSelected = this.onPanelSelected.bind(this); 2401 this.onDetachedFront = this.onDetachedFront.bind(this); 2402 this.onSelected = this.onSelected.bind(this); 2403 this.onViewRefreshed = this.onViewRefreshed.bind(this); 2404 2405 this.#abortController = new window.AbortController(); 2406 const { signal } = this.#abortController; 2407 const baseEventConfig = { signal }; 2408 2409 this.view.on("ruleview-refreshed", this.onViewRefreshed, baseEventConfig); 2410 this.inspector.selection.on( 2411 "detached-front", 2412 this.onDetachedFront, 2413 baseEventConfig 2414 ); 2415 this.inspector.selection.on( 2416 "new-node-front", 2417 this.onSelected, 2418 baseEventConfig 2419 ); 2420 this.inspector.selection.on("pseudoclass", this.refresh, baseEventConfig); 2421 this.inspector.ruleViewSideBar.on( 2422 "ruleview-selected", 2423 this.onPanelSelected, 2424 baseEventConfig 2425 ); 2426 this.inspector.sidebar.on( 2427 "ruleview-selected", 2428 this.onPanelSelected, 2429 baseEventConfig 2430 ); 2431 this.inspector.toolbox.on( 2432 "inspector-selected", 2433 this.onPanelSelected, 2434 baseEventConfig 2435 ); 2436 this.inspector.styleChangeTracker.on( 2437 "style-changed", 2438 this.refresh, 2439 baseEventConfig 2440 ); 2441 2442 this.inspector.commands.resourceCommand.watchResources( 2443 [ 2444 this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT, 2445 this.inspector.commands.resourceCommand.TYPES.STYLESHEET, 2446 ], 2447 { 2448 onAvailable: this.#onResourceAvailable, 2449 ignoreExistingResources: true, 2450 } 2451 ); 2452 2453 // We do want to get already existing registered properties, so we need to watch 2454 // them separately 2455 this.inspector.commands.resourceCommand 2456 .watchResources( 2457 [ 2458 this.inspector.commands.resourceCommand.TYPES 2459 .CSS_REGISTERED_PROPERTIES, 2460 ], 2461 { 2462 onAvailable: this.#onResourceAvailable, 2463 onUpdated: this.#onResourceUpdated, 2464 onDestroyed: this.#onResourceDestroyed, 2465 ignoreExistingResources: false, 2466 } 2467 ) 2468 .catch(e => { 2469 // watchResources is async and even making it's resulting promise part of 2470 // this.readyPromise still causes test failures, so simply ignore the rejection 2471 // if the view was already destroyed. 2472 if (!this.view) { 2473 return; 2474 } 2475 throw e; 2476 }); 2477 2478 // At the moment `readyPromise` is only consumed in tests (see `openRuleView`) to be 2479 // notified when the ruleview was first populated to match the initial selected node. 2480 this.readyPromise = this.onSelected(); 2481 } 2482 2483 #abortController; 2484 2485 isPanelVisible() { 2486 if (!this.view) { 2487 return false; 2488 } 2489 return this.view.isPanelVisible(); 2490 } 2491 2492 onDetachedFront() { 2493 this.onSelected(false); 2494 } 2495 2496 onSelected(selectElement = true) { 2497 // Ignore the event if the view has been destroyed, or if it's inactive. 2498 // But only if the current selection isn't null. If it's been set to null, 2499 // let the update go through as this is needed to empty the view on 2500 // navigation. 2501 if (!this.view) { 2502 return null; 2503 } 2504 2505 const isInactive = 2506 !this.isPanelVisible() && this.inspector.selection.nodeFront; 2507 if (isInactive) { 2508 return null; 2509 } 2510 2511 if ( 2512 !this.inspector.selection.isConnected() || 2513 !this.inspector.selection.isElementNode() 2514 ) { 2515 return this.view.selectElement(null); 2516 } 2517 2518 if (!selectElement) { 2519 return null; 2520 } 2521 2522 const done = this.inspector.updating("rule-view"); 2523 return this.view 2524 .selectElement(this.inspector.selection.nodeFront) 2525 .then(done, done); 2526 } 2527 2528 refresh() { 2529 if (this.isPanelVisible()) { 2530 this.view.refreshPanel(); 2531 } 2532 } 2533 2534 #onResourceAvailable = resources => { 2535 if (!this.inspector) { 2536 return; 2537 } 2538 2539 let hasNewStylesheet = false; 2540 const addedRegisteredProperties = []; 2541 for (const resource of resources) { 2542 if ( 2543 resource.resourceType === 2544 this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT && 2545 resource.name === "will-navigate" 2546 ) { 2547 this.view.cssRegisteredPropertiesByTarget.delete(resource.targetFront); 2548 if (resource.targetFront.isTopLevel) { 2549 this.clearUserProperties(); 2550 } 2551 continue; 2552 } 2553 2554 if ( 2555 resource.resourceType === 2556 this.inspector.commands.resourceCommand.TYPES.STYLESHEET && 2557 // resource.isNew is only true when the stylesheet was added from DevTools, 2558 // for example when adding a rule in the rule view. In such cases, we're already 2559 // updating the rule view, so ignore those. 2560 !resource.isNew 2561 ) { 2562 hasNewStylesheet = true; 2563 } 2564 2565 if ( 2566 resource.resourceType === 2567 this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES 2568 ) { 2569 if ( 2570 !this.view.cssRegisteredPropertiesByTarget.has(resource.targetFront) 2571 ) { 2572 this.view.cssRegisteredPropertiesByTarget.set( 2573 resource.targetFront, 2574 new Map() 2575 ); 2576 } 2577 this.view.cssRegisteredPropertiesByTarget 2578 .get(resource.targetFront) 2579 .set(resource.name, resource); 2580 // Only add properties from the same target as the selected node 2581 if ( 2582 this.view.inspector.selection?.nodeFront?.targetFront === 2583 resource.targetFront 2584 ) { 2585 addedRegisteredProperties.push(resource); 2586 } 2587 } 2588 } 2589 2590 if (addedRegisteredProperties.length) { 2591 // Retrieve @property container 2592 let registeredPropertiesContainer = 2593 this.view.styleDocument.getElementById( 2594 REGISTERED_PROPERTIES_CONTAINER_ID 2595 ); 2596 // create it if it didn't exist before 2597 if (!registeredPropertiesContainer) { 2598 registeredPropertiesContainer = 2599 this.view.createRegisteredPropertiesExpandableContainer(); 2600 } 2601 2602 // Then add all new registered properties 2603 const names = new Set(); 2604 for (const propertyDefinition of addedRegisteredProperties) { 2605 const editor = new RegisteredPropertyEditor( 2606 this.view, 2607 propertyDefinition 2608 ); 2609 names.add(propertyDefinition.name); 2610 2611 // We need to insert the element at the right position so we keep the list of 2612 // properties alphabetically sorted. 2613 let referenceNode = null; 2614 for (const child of registeredPropertiesContainer.children) { 2615 if (child.getAttribute("data-name") > propertyDefinition.name) { 2616 referenceNode = child; 2617 break; 2618 } 2619 } 2620 registeredPropertiesContainer.insertBefore( 2621 editor.element, 2622 referenceNode 2623 ); 2624 } 2625 2626 // Finally, update textProps that might rely on those new properties 2627 this._updateElementStyleRegisteredProperties(names); 2628 } 2629 2630 if (hasNewStylesheet) { 2631 this.refresh(); 2632 } 2633 }; 2634 2635 #onResourceUpdated = updates => { 2636 const updatedProperties = []; 2637 for (const update of updates) { 2638 if ( 2639 update.resource.resourceType === 2640 this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES 2641 ) { 2642 const { resource } = update; 2643 if ( 2644 !this.view.cssRegisteredPropertiesByTarget.has(resource.targetFront) 2645 ) { 2646 continue; 2647 } 2648 2649 this.view.cssRegisteredPropertiesByTarget 2650 .get(resource.targetFront) 2651 .set(resource.name, resource); 2652 2653 // Only consider properties from the same target as the selected node 2654 if ( 2655 this.view.inspector.selection?.nodeFront?.targetFront === 2656 resource.targetFront 2657 ) { 2658 updatedProperties.push(resource); 2659 } 2660 } 2661 } 2662 2663 const names = new Set(); 2664 if (updatedProperties.length) { 2665 const registeredPropertiesContainer = 2666 this.view.styleDocument.getElementById( 2667 REGISTERED_PROPERTIES_CONTAINER_ID 2668 ); 2669 for (const resource of updatedProperties) { 2670 // Replace the existing registered property editor element with a new one, 2671 // so we don't have to compute which elements should be updated. 2672 const name = resource.name; 2673 const el = this.view.getRegisteredPropertyElement(name); 2674 const editor = new RegisteredPropertyEditor(this.view, resource); 2675 registeredPropertiesContainer.replaceChild(editor.element, el); 2676 2677 names.add(resource.name); 2678 } 2679 // Finally, update textProps that might rely on those new properties 2680 this._updateElementStyleRegisteredProperties(names); 2681 } 2682 }; 2683 2684 #onResourceDestroyed = resources => { 2685 const destroyedPropertiesNames = new Set(); 2686 for (const resource of resources) { 2687 if ( 2688 resource.resourceType === 2689 this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES 2690 ) { 2691 if ( 2692 !this.view.cssRegisteredPropertiesByTarget.has(resource.targetFront) 2693 ) { 2694 continue; 2695 } 2696 2697 const targetRegisteredProperties = 2698 this.view.cssRegisteredPropertiesByTarget.get(resource.targetFront); 2699 const resourceName = Array.from( 2700 targetRegisteredProperties.entries() 2701 ).find( 2702 ([_, propDef]) => propDef.resourceId === resource.resourceId 2703 )?.[0]; 2704 if (!resourceName) { 2705 continue; 2706 } 2707 2708 targetRegisteredProperties.delete(resourceName); 2709 2710 // Only consider properties from the same target as the selected node 2711 if ( 2712 this.view.inspector.selection?.nodeFront?.targetFront === 2713 resource.targetFront 2714 ) { 2715 destroyedPropertiesNames.add(resourceName); 2716 } 2717 } 2718 } 2719 if (destroyedPropertiesNames.size > 0) { 2720 for (const name of destroyedPropertiesNames) { 2721 this.view.getRegisteredPropertyElement(name)?.remove(); 2722 } 2723 // Finally, update textProps that were relying on those removed properties 2724 this._updateElementStyleRegisteredProperties(destroyedPropertiesNames); 2725 } 2726 }; 2727 2728 /** 2729 * Update rules that reference registered properties whose name is in the passed Set, 2730 * so the `var()` tooltip has up-to-date information. 2731 * 2732 * @param {Set<string>} registeredPropertyNames 2733 */ 2734 _updateElementStyleRegisteredProperties(registeredPropertyNames) { 2735 if (!this.view._elementStyle) { 2736 return; 2737 } 2738 this.view._elementStyle.onRegisteredPropertiesChange( 2739 registeredPropertyNames 2740 ); 2741 } 2742 2743 clearUserProperties() { 2744 if (this.view && this.view.store && this.view.store.userProperties) { 2745 this.view.store.userProperties.clear(); 2746 } 2747 } 2748 2749 onPanelSelected() { 2750 if (this.inspector.selection.nodeFront === this.view._viewedElement) { 2751 this.refresh(); 2752 } else { 2753 this.onSelected(); 2754 } 2755 } 2756 2757 onViewRefreshed() { 2758 this.inspector.emit("rule-view-refreshed"); 2759 } 2760 2761 destroy() { 2762 if (this.#abortController) { 2763 this.#abortController.abort(); 2764 } 2765 2766 this.inspector.commands.resourceCommand.unwatchResources( 2767 [ 2768 this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT, 2769 this.inspector.commands.resourceCommand.TYPES.STYLESHEET, 2770 ], 2771 { 2772 onAvailable: this.#onResourceAvailable, 2773 } 2774 ); 2775 2776 this.inspector.commands.resourceCommand.unwatchResources( 2777 [this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES], 2778 { 2779 onAvailable: this.#onResourceAvailable, 2780 onUpdated: this.#onResourceUpdated, 2781 onDestroyed: this.#onResourceDestroyed, 2782 } 2783 ); 2784 2785 this.view.destroy(); 2786 2787 this.view = 2788 this.document = 2789 this.inspector = 2790 this.readyPromise = 2791 this.store = 2792 this.#abortController = 2793 null; 2794 } 2795 } 2796 2797 exports.CssRuleView = CssRuleView; 2798 exports.RuleViewTool = RuleViewTool;