rule-editor.js (45201B)
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 { l10n } = require("resource://devtools/shared/inspector/css-logic.js"); 8 const { 9 PSEUDO_CLASSES, 10 } = require("resource://devtools/shared/css/constants.js"); 11 const { 12 style: { ELEMENT_STYLE, PRES_HINTS }, 13 } = require("resource://devtools/shared/constants.js"); 14 const Rule = require("resource://devtools/client/inspector/rules/models/rule.js"); 15 const { 16 InplaceEditor, 17 editableField, 18 editableItem, 19 } = require("resource://devtools/client/shared/inplace-editor.js"); 20 const TextPropertyEditor = require("resource://devtools/client/inspector/rules/views/text-property-editor.js"); 21 const { 22 createChild, 23 blurOnMultipleProperties, 24 promiseWarn, 25 } = require("resource://devtools/client/inspector/shared/utils.js"); 26 const { 27 parseNamedDeclarations, 28 parsePseudoClassesAndAttributes, 29 SELECTOR_ATTRIBUTE, 30 SELECTOR_ELEMENT, 31 SELECTOR_PSEUDO_CLASS, 32 } = require("resource://devtools/shared/css/parsing-utils.js"); 33 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 34 const CssLogic = require("resource://devtools/shared/inspector/css-logic.js"); 35 36 loader.lazyRequireGetter( 37 this, 38 "Tools", 39 "resource://devtools/client/definitions.js", 40 true 41 ); 42 loader.lazyRequireGetter( 43 this, 44 "PluralForm", 45 "resource://devtools/shared/plural-form.js", 46 true 47 ); 48 49 const STYLE_INSPECTOR_PROPERTIES = 50 "devtools/shared/locales/styleinspector.properties"; 51 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 52 const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES); 53 54 const COMPONENT_PROPERTIES = "devtools/client/locales/components.properties"; 55 const COMPONENT_L10N = new LocalizationHelper(COMPONENT_PROPERTIES); 56 57 loader.lazyGetter(this, "NEW_PROPERTY_NAME_INPUT_LABEL", function () { 58 return STYLE_INSPECTOR_L10N.getStr("rule.newPropertyName.label"); 59 }); 60 61 const UNUSED_CSS_PROPERTIES_HIDE_THRESHOLD = 10; 62 const INDENT_SIZE = 2; 63 const INDENT_STR = " ".repeat(INDENT_SIZE); 64 65 /** 66 * RuleEditor is responsible for the following: 67 * Owns a Rule object and creates a list of TextPropertyEditors 68 * for its TextProperties. 69 * Manages creation of new text properties. 70 */ 71 class RuleEditor extends EventEmitter { 72 /** 73 * @param {CssRuleView} ruleView 74 * The CssRuleView containg the document holding this rule editor. 75 * @param {Rule} rule 76 * The Rule object we're editing. 77 * @param {object} options 78 * @param {Set} options.elementsWithPendingClicks 79 * @param {Function} options.onShowUnusedCustomCssProperties 80 * @param {boolean} options.shouldHideUnusedCustomCssProperties 81 */ 82 constructor(ruleView, rule, options = {}) { 83 super(); 84 85 this.ruleView = ruleView; 86 this.doc = this.ruleView.styleDocument; 87 this.toolbox = this.ruleView.inspector.toolbox; 88 this.telemetry = this.toolbox.telemetry; 89 this.rule = rule; 90 this.options = options; 91 92 this.isEditable = rule.isEditable(); 93 // Flag that blocks updates of the selector and properties when it is 94 // being edited 95 this.isEditing = false; 96 97 this._onNewProperty = this._onNewProperty.bind(this); 98 this._newPropertyDestroy = this._newPropertyDestroy.bind(this); 99 this._onSelectorDone = this._onSelectorDone.bind(this); 100 this._locationChanged = this._locationChanged.bind(this); 101 this.updateSourceLink = this.updateSourceLink.bind(this); 102 this._onToolChanged = this._onToolChanged.bind(this); 103 this._updateLocation = this._updateLocation.bind(this); 104 this._onSourceClick = this._onSourceClick.bind(this); 105 this._onShowUnusedCustomCssPropertiesButtonClick = 106 this._onShowUnusedCustomCssPropertiesButtonClick.bind(this); 107 108 this.rule.domRule.on("location-changed", this._locationChanged); 109 this.toolbox.on("tool-registered", this._onToolChanged); 110 this.toolbox.on("tool-unregistered", this._onToolChanged); 111 112 this._create(); 113 } 114 destroy() { 115 for (const prop of this.rule.textProps) { 116 prop.editor?.destroy(); 117 } 118 119 this._unusedCssVariableDeclarations = null; 120 121 if (this._showUnusedCustomCssPropertiesButton) { 122 this._nullifyShowUnusedCustomCssProperties({ removeFromDom: false }); 123 } 124 125 this.rule.domRule.off("location-changed"); 126 this.toolbox.off("tool-registered", this._onToolChanged); 127 this.toolbox.off("tool-unregistered", this._onToolChanged); 128 129 if (this._unsubscribeSourceMap) { 130 this._unsubscribeSourceMap(); 131 } 132 } 133 134 get sourceMapURLService() { 135 if (!this._sourceMapURLService) { 136 // sourceMapURLService is a lazy getter in the toolbox. 137 this._sourceMapURLService = this.toolbox.sourceMapURLService; 138 } 139 140 return this._sourceMapURLService; 141 } 142 143 get isSelectorEditable() { 144 return ( 145 this.isEditable && 146 this.rule.domRule.type !== ELEMENT_STYLE && 147 this.rule.domRule.type !== CSSRule.KEYFRAME_RULE && 148 this.rule.domRule.className !== "CSSPositionTryRule" 149 ); 150 } 151 152 get showSelectorHighlighterButton() { 153 return ( 154 this.rule.domRule.type !== CSSRule.KEYFRAME_RULE && 155 this.rule.domRule.className !== "CSSPositionTryRule" 156 ); 157 } 158 159 _create() { 160 this.element = this.doc.createElement("div"); 161 this.element.className = 162 "ruleview-rule devtools-monospace" + 163 (this.rule.inherited ? " ruleview-rule-inherited" : ""); 164 this.element.dataset.ruleId = this.rule.domRule.actorID; 165 this.element.setAttribute("uneditable", !this.isEditable); 166 this.element.setAttribute("unmatched", this.rule.isUnmatched); 167 this.element._ruleEditor = this; 168 169 // Give a relative position for the inplace editor's measurement 170 // span to be placed absolutely against. 171 this.element.style.position = "relative"; 172 173 // Add the source link for supported rules. inline style and pres hints are not visible 174 // in the StyleEditor, so don't show anything for such rule. 175 if ( 176 this.rule.domRule.type !== ELEMENT_STYLE && 177 this.rule.domRule.type !== PRES_HINTS 178 ) { 179 this.source = createChild(this.element, "div", { 180 class: "ruleview-rule-source theme-link", 181 }); 182 this.source.addEventListener("click", this._onSourceClick); 183 184 const sourceLabel = this.doc.createElement("a"); 185 sourceLabel.classList.add("ruleview-rule-source-label"); 186 this.source.appendChild(sourceLabel); 187 } 188 this.updateSourceLink(); 189 190 if (this.rule.domRule.ancestorData.length) { 191 const ancestorsFrag = this.doc.createDocumentFragment(); 192 this.rule.domRule.ancestorData.forEach((ancestorData, index) => { 193 const ancestorItem = this.doc.createElement("div"); 194 ancestorItem.setAttribute("role", "listitem"); 195 ancestorsFrag.append(ancestorItem); 196 ancestorItem.setAttribute("data-ancestor-index", index); 197 ancestorItem.classList.add("ruleview-rule-ancestor"); 198 if (ancestorData.type) { 199 ancestorItem.classList.add(ancestorData.type); 200 } 201 202 // Indent each parent selector 203 if (index) { 204 createChild(ancestorItem, "span", { 205 class: "ruleview-rule-indent", 206 textContent: INDENT_STR.repeat(index), 207 }); 208 } 209 210 const selectorContainer = createChild(ancestorItem, "span", { 211 class: "ruleview-rule-ancestor-selectorcontainer", 212 }); 213 214 if (ancestorData.type == "container") { 215 ancestorItem.classList.add("container-query", "has-tooltip"); 216 217 createChild(selectorContainer, "span", { 218 class: "container-query-declaration", 219 textContent: `@container${ancestorData.containerName ? " " + ancestorData.containerName : ""}`, 220 }); 221 222 const jumpToNodeButton = createChild(selectorContainer, "button", { 223 class: "open-inspector", 224 title: l10n("rule.containerQuery.selectContainerButton.tooltip"), 225 }); 226 227 let containerNodeFront; 228 const getNodeFront = async () => { 229 if (!containerNodeFront) { 230 const res = await this.rule.domRule.getQueryContainerForNode( 231 index, 232 this.rule.inherited || 233 this.ruleView.inspector.selection.nodeFront 234 ); 235 containerNodeFront = res.node; 236 } 237 return containerNodeFront; 238 }; 239 240 jumpToNodeButton.addEventListener("click", async () => { 241 const front = await getNodeFront(); 242 if (!front) { 243 return; 244 } 245 this.ruleView.inspector.selection.setNodeFront(front); 246 await this.ruleView.inspector.highlighters.hideHighlighterType( 247 this.ruleView.inspector.highlighters.TYPES.BOXMODEL 248 ); 249 }); 250 251 ancestorItem.addEventListener("mouseenter", async () => { 252 const front = await getNodeFront(); 253 if (!front) { 254 return; 255 } 256 257 await this.ruleView.inspector.highlighters.showHighlighterTypeForNode( 258 this.ruleView.inspector.highlighters.TYPES.BOXMODEL, 259 front 260 ); 261 }); 262 ancestorItem.addEventListener("mouseleave", async () => { 263 await this.ruleView.inspector.highlighters.hideHighlighterType( 264 this.ruleView.inspector.highlighters.TYPES.BOXMODEL 265 ); 266 }); 267 268 createChild(selectorContainer, "span", { 269 // Add a space between the container name (or @container if there's no name) 270 // and the query so the title, which is computed from the DOM, displays correctly. 271 textContent: " " + ancestorData.containerQuery, 272 }); 273 } else if (ancestorData.type == "layer") { 274 selectorContainer.append( 275 this.doc.createTextNode( 276 `@layer${ancestorData.value ? " " + ancestorData.value : ""}` 277 ) 278 ); 279 } else if (ancestorData.type == "media") { 280 selectorContainer.append( 281 this.doc.createTextNode(`@media ${ancestorData.value}`) 282 ); 283 } else if (ancestorData.type == "supports") { 284 selectorContainer.append( 285 this.doc.createTextNode(`@supports ${ancestorData.conditionText}`) 286 ); 287 } else if (ancestorData.type == "import") { 288 selectorContainer.append( 289 this.doc.createTextNode(`@import ${ancestorData.value}`) 290 ); 291 } else if (ancestorData.type == "scope") { 292 let text = `@scope`; 293 if (ancestorData.start) { 294 text += ` (${ancestorData.start})`; 295 296 if (ancestorData.end) { 297 text += ` to (${ancestorData.end})`; 298 } 299 } 300 selectorContainer.append(this.doc.createTextNode(text)); 301 } else if (ancestorData.type == "starting-style") { 302 selectorContainer.append(this.doc.createTextNode(`@starting-style`)); 303 } else if (ancestorData.selectors) { 304 ancestorData.selectors.forEach((selector, i) => { 305 if (i !== 0) { 306 createChild(selectorContainer, "span", { 307 class: "ruleview-selector-separator", 308 textContent: ", ", 309 }); 310 } 311 312 const selectorEl = createChild(selectorContainer, "span", { 313 class: "ruleview-selector", 314 textContent: selector, 315 }); 316 317 const warningsContainer = this._createWarningsElementForSelector( 318 i, 319 ancestorData.selectorWarnings 320 ); 321 if (warningsContainer) { 322 selectorEl.append(warningsContainer); 323 } 324 }); 325 } else { 326 // We shouldn't get here as `type` should only match to what can be set in 327 // the StyleRuleActor form, but just in case, let's return an empty string. 328 console.warn("Unknown ancestor data type:", ancestorData.type); 329 return; 330 } 331 332 createChild(ancestorItem, "span", { 333 class: "ruleview-ancestor-ruleopen", 334 textContent: " {", 335 }); 336 }); 337 338 // We can't use a proper "ol" as it will mess with selection copy text, 339 // adding spaces on list item instead of the one we craft (.ruleview-rule-indent) 340 this.ancestorDataEl = createChild(this.element, "div", { 341 class: "ruleview-rule-ancestor-data theme-link", 342 role: "list", 343 }); 344 this.ancestorDataEl.append(ancestorsFrag); 345 } 346 347 this.ruleviewCodeEl = createChild(this.element, "div", { 348 class: "ruleview-code", 349 }); 350 351 const header = createChild(this.ruleviewCodeEl, "div", {}); 352 353 createChild(header, "span", { 354 class: "ruleview-rule-indent", 355 textContent: INDENT_STR.repeat(this.rule.domRule.ancestorData.length), 356 }); 357 358 this.selectorText = createChild(header, "span", { 359 class: "ruleview-selectors-container", 360 tabindex: this.isSelectorEditable ? "0" : "-1", 361 }); 362 363 if ( 364 this.rule.domRule.type === ELEMENT_STYLE || 365 this.rule.domRule.type === PRES_HINTS 366 ) { 367 this.selectorText.classList.add("alternative-selector"); 368 } 369 370 if (this.isSelectorEditable) { 371 this.selectorText.addEventListener("click", event => { 372 // Clicks within the selector shouldn't propagate any further. 373 event.stopPropagation(); 374 }); 375 376 editableField({ 377 element: this.selectorText, 378 done: this._onSelectorDone, 379 cssProperties: this.rule.cssProperties, 380 // (Shift+)Tab will move the focus to the previous/next editable field (so property name, 381 // or new property of the previous rule). 382 focusEditableFieldAfterApply: true, 383 focusEditableFieldContainerSelector: ".ruleview-rule", 384 // We don't want Enter to trigger the next editable field, just to validate 385 // what the user entered, close the editor, and focus the span so the user can 386 // navigate with the keyboard as expected, unless the user has 387 // devtools.inspector.rule-view.focusNextOnEnter set to true 388 stopOnReturn: this.ruleView.inplaceEditorFocusNextOnEnter !== true, 389 }); 390 } else { 391 this.selectorText.classList.add("uneditable-selector"); 392 } 393 394 if (this.rule.domRule.type !== CSSRule.KEYFRAME_RULE) { 395 // This is a "normal" rule with a selector. 396 let computedSelector = ""; 397 if (this.rule.domRule.selectors) { 398 computedSelector = this.rule.domRule.computedSelector; 399 // Otherwise, the rule is either inherited or inline, and selectors will 400 // be computed on demand when the highlighter is requested. 401 } 402 403 if (this.showSelectorHighlighterButton) { 404 const isHighlighted = 405 this.ruleView.isSelectorHighlighted(computedSelector); 406 // Handling of click events is delegated to CssRuleView.handleEvent() 407 createChild(header, "button", { 408 class: 409 "ruleview-selectorhighlighter js-toggle-selector-highlighter" + 410 (isHighlighted ? " highlighted" : ""), 411 "aria-pressed": isHighlighted, 412 // This is used in rules.js for the selector highlighter 413 "data-computed-selector": computedSelector, 414 title: l10n("rule.selectorHighlighter.tooltip"), 415 }); 416 } 417 } 418 419 this.openBrace = createChild(header, "span", { 420 class: "ruleview-ruleopen", 421 textContent: " {", 422 }); 423 424 // We can't use a proper "ol" as it will mess with selection copy text, 425 // adding spaces on list item instead of the one we craft (.ruleview-rule-indent) 426 this.propertyList = createChild(this.ruleviewCodeEl, "div", { 427 class: "ruleview-propertylist", 428 role: "list", 429 }); 430 431 this.populate(); 432 433 this.closeBrace = createChild(this.ruleviewCodeEl, "div", { 434 class: "ruleview-ruleclose", 435 tabindex: this.isEditable ? "0" : "-1", 436 }); 437 438 if (this.rule.domRule.ancestorData.length) { 439 createChild(this.closeBrace, "span", { 440 class: "ruleview-rule-indent", 441 textContent: INDENT_STR.repeat(this.rule.domRule.ancestorData.length), 442 }); 443 } 444 this.closeBrace.append(this.doc.createTextNode("}")); 445 446 if (this.rule.domRule.ancestorData.length) { 447 let closingBracketsText = ""; 448 for (let i = this.rule.domRule.ancestorData.length - 1; i >= 0; i--) { 449 if (i) { 450 closingBracketsText += INDENT_STR.repeat(i); 451 } 452 closingBracketsText += "}\n"; 453 } 454 createChild(this.ruleviewCodeEl, "div", { 455 class: "ruleview-ancestor-ruleclose", 456 textContent: closingBracketsText, 457 }); 458 } 459 460 if (this.isEditable) { 461 // A newProperty editor should only be created when no editor was 462 // previously displayed. Since the editors are cleared on blur, 463 // check this.ruleview.isEditing on mousedown 464 this._ruleViewIsEditing = false; 465 466 this.ruleviewCodeEl.addEventListener("mousedown", () => { 467 this._ruleViewIsEditing = this.ruleView.isEditing; 468 }); 469 470 this.ruleviewCodeEl.addEventListener("click", () => { 471 const selection = this.doc.defaultView.getSelection(); 472 if (selection.isCollapsed && !this._ruleViewIsEditing) { 473 this.newProperty(); 474 } 475 // Cleanup the _ruleViewIsEditing flag 476 this._ruleViewIsEditing = false; 477 }); 478 479 this.element.addEventListener("mousedown", () => { 480 this.doc.defaultView.focus(); 481 }); 482 483 // Create a property editor when the close brace is clicked. 484 editableItem({ element: this.closeBrace }, () => { 485 this.newProperty(); 486 }); 487 } 488 } 489 490 /** 491 * Returns the selector warnings element, or null if selector at selectorIndex 492 * does not have any warning. 493 * 494 * @param {Integer} selectorIndex: The index of the selector we want to create the 495 * warnings for 496 * @param {Array<object>} selectorWarnings: An array of object of the following shape: 497 * - {Integer} index: The index of the selector this applies to 498 * - {String} kind: Identifies the warning 499 * @returns {Element|null} 500 */ 501 _createWarningsElementForSelector(selectorIndex, selectorWarnings) { 502 if (!selectorWarnings) { 503 return null; 504 } 505 506 const warningKinds = []; 507 for (const { index, kind } of selectorWarnings) { 508 if (index !== selectorIndex) { 509 continue; 510 } 511 warningKinds.push(kind); 512 } 513 514 if (!warningKinds.length) { 515 return null; 516 } 517 518 const warningsContainer = this.doc.createElement("div"); 519 warningsContainer.classList.add( 520 "ruleview-selector-warnings", 521 "has-tooltip" 522 ); 523 524 warningsContainer.setAttribute( 525 "data-selector-warning-kind", 526 warningKinds.join(",") 527 ); 528 529 if (warningKinds.includes("UnconstrainedHas")) { 530 warningsContainer.classList.add("slow"); 531 } 532 533 return warningsContainer; 534 } 535 536 /** 537 * Called when a tool is registered or unregistered. 538 */ 539 _onToolChanged() { 540 if (!this.source) { 541 return; 542 } 543 544 // When the source editor is registered, update the source links 545 // to be clickable; and if it is unregistered, update the links to 546 // be unclickable. 547 if (this.toolbox.isToolRegistered("styleeditor")) { 548 this.source.removeAttribute("unselectable"); 549 } else { 550 this.source.setAttribute("unselectable", "true"); 551 } 552 } 553 554 /** 555 * Event handler called when a property changes on the 556 * StyleRuleActor. 557 */ 558 _locationChanged() { 559 this.updateSourceLink(); 560 } 561 562 _onSourceClick(e) { 563 e.preventDefault(); 564 if (this.source.hasAttribute("unselectable")) { 565 return; 566 } 567 568 const { inspector } = this.ruleView; 569 if (Tools.styleEditor.isToolSupported(inspector.toolbox)) { 570 inspector.toolbox.viewSourceInStyleEditorByResource( 571 this.rule.sheet, 572 this.rule.ruleLine, 573 this.rule.ruleColumn 574 ); 575 } 576 } 577 578 /** 579 * Update the text of the source link to reflect whether we're showing 580 * original sources or not. This is a callback for 581 * SourceMapURLService.subscribeByID, which see. 582 * 583 * @param {object | null} originalLocation 584 * The original position object (url/line/column) or null. 585 */ 586 _updateLocation(originalLocation) { 587 let displayURL = this.rule.sheet?.href; 588 const constructed = this.rule.sheet?.constructed; 589 let line = this.rule.ruleLine; 590 if (originalLocation) { 591 displayURL = originalLocation.url; 592 line = originalLocation.line; 593 } 594 595 let sourceTextContent = CssLogic.shortSource({ 596 constructed, 597 href: displayURL, 598 }); 599 600 let displayLocation = displayURL ? displayURL : sourceTextContent; 601 if (line > 0) { 602 sourceTextContent += ":" + line; 603 displayLocation += ":" + line; 604 } 605 const title = COMPONENT_L10N.getFormatStr( 606 "frame.viewsourceinstyleeditor", 607 displayLocation 608 ); 609 610 const sourceLabel = this.element.querySelector( 611 ".ruleview-rule-source-label" 612 ); 613 sourceLabel.setAttribute("title", title); 614 sourceLabel.setAttribute("href", displayURL); 615 sourceLabel.textContent = sourceTextContent; 616 } 617 618 updateSourceLink() { 619 if (this.source) { 620 if (this.rule.isSystem) { 621 const sourceLabel = this.element.querySelector( 622 ".ruleview-rule-source-label" 623 ); 624 const uaLabel = STYLE_INSPECTOR_L10N.getStr("rule.userAgentStyles"); 625 sourceLabel.textContent = uaLabel + " " + this.rule.title; 626 sourceLabel.setAttribute("href", this.rule.sheet?.href); 627 } else { 628 this._updateLocation(null); 629 } 630 631 if (this.rule.sheet && !this.rule.isSystem) { 632 // Only get the original source link if the rule isn't a system 633 // rule and if it isn't an inline rule. 634 if (this._unsubscribeSourceMap) { 635 this._unsubscribeSourceMap(); 636 } 637 this._unsubscribeSourceMap = this.sourceMapURLService.subscribeByID( 638 this.rule.sheet.resourceId, 639 this.rule.ruleLine, 640 this.rule.ruleColumn, 641 this._updateLocation 642 ); 643 } 644 // Set "unselectable" appropriately. 645 this._onToolChanged(); 646 } 647 648 Promise.resolve().then(() => { 649 this.emit("source-link-updated"); 650 }); 651 } 652 653 /** 654 * Update the rule editor with the contents of the rule. 655 * 656 * @param {boolean} reset 657 * True to completely reset the rule editor before populating. 658 */ 659 populate(reset) { 660 // Clear out existing viewers. 661 this.selectorText.replaceChildren(); 662 663 // If selector text comes from a css rule, highlight selectors that 664 // actually match. For custom selector text (such as for the 'element' 665 // style, just show the text directly. 666 if ( 667 this.rule.domRule.type === ELEMENT_STYLE || 668 this.rule.domRule.type === PRES_HINTS 669 ) { 670 this.selectorText.textContent = this.rule.selectorText; 671 } else if (this.rule.domRule.type === CSSRule.KEYFRAME_RULE) { 672 this.selectorText.textContent = this.rule.domRule.keyText; 673 } else if (this.rule.domRule.className === "CSSPositionTryRule") { 674 this.selectorText.textContent = this.rule.domRule.name; 675 } else { 676 this.rule.domRule.selectors.forEach((selector, i) => { 677 this._populateSelector(selector, i); 678 }); 679 } 680 681 let focusedElSelector; 682 if (reset) { 683 // If we're going to reset the rule (i.e. if this is the `element` rule), 684 // we want to restore the focus after the rule is populated. 685 // So if this element contains the active element, retrieve its selector for later use. 686 if (this.element.contains(this.doc.activeElement)) { 687 focusedElSelector = CssLogic.findCssSelector(this.doc.activeElement); 688 } 689 690 this.propertyList.replaceChildren(); 691 } 692 693 this._unusedCssVariableDeclarations = 694 this._getUnusedCssVariableDeclarations(); 695 const hideUnusedCssVariableDeclarations = 696 this._unusedCssVariableDeclarations.size >= 697 // If the button was already displayed, hide unused variables if we have at least 698 // one, even if it's less than the threshold 699 (this._showUnusedCustomCssPropertiesButton 700 ? 1 701 : UNUSED_CSS_PROPERTIES_HIDE_THRESHOLD); 702 703 // If we won't hide any variable, clear the Set of unused variables as it's used in 704 // updateUnusedCssVariables and we might do unnecessary computation if we still 705 // track variables which are actually visible. 706 if (!hideUnusedCssVariableDeclarations) { 707 this._unusedCssVariableDeclarations.clear(); 708 } 709 710 for (const prop of this.rule.textProps) { 711 if (hideUnusedCssVariableDeclarations && prop.isUnusedVariable) { 712 continue; 713 } 714 if (!prop.editor && !prop.invisible) { 715 const editor = new TextPropertyEditor(this, prop, { 716 elementsWithPendingClicks: this.options.elementsWithPendingClicks, 717 }); 718 this.propertyList.appendChild(editor.element); 719 } else if (prop.editor) { 720 // If an editor already existed, append it to the bottom now to make sure the 721 // order of editors in the DOM follow the order of the rule's properties. 722 this.propertyList.appendChild(prop.editor.element); 723 } 724 } 725 726 if (hideUnusedCssVariableDeclarations) { 727 if (!this._showUnusedCustomCssPropertiesButton) { 728 this._showUnusedCustomCssPropertiesButton = 729 this.doc.createElement("button"); 730 this._showUnusedCustomCssPropertiesButton.classList.add( 731 "devtools-button", 732 "devtools-button-standalone", 733 "ruleview-show-unused-custom-css-properties" 734 ); 735 this._showUnusedCustomCssPropertiesButton.addEventListener( 736 "click", 737 this._onShowUnusedCustomCssPropertiesButtonClick 738 ); 739 } 740 this.ruleviewCodeEl.insertBefore( 741 this._showUnusedCustomCssPropertiesButton, 742 this.closeBrace 743 ); 744 this._updateShowUnusedCustomCssPropertiesButtonText(); 745 } else if (this._showUnusedCustomCssPropertiesButton) { 746 this._nullifyShowUnusedCustomCssProperties(); 747 } 748 749 // Set focus if the focus is still in the current document (avoid stealing 750 // the focus, see Bug 1911627). 751 if (this.doc.hasFocus() && focusedElSelector) { 752 const elementToFocus = this.doc.querySelector(focusedElSelector); 753 if (elementToFocus && this.element.contains(elementToFocus)) { 754 // We need to wait for a tick for the focus to be properly set 755 setTimeout(() => { 756 elementToFocus.focus(); 757 this.ruleView.emitForTests("rule-editor-focus-reset"); 758 }, 0); 759 } 760 } 761 } 762 763 updateUnusedCssVariables() { 764 if ( 765 !this._unusedCssVariableDeclarations || 766 !this._unusedCssVariableDeclarations.size 767 ) { 768 return; 769 } 770 771 // Store the list of what used to be unused 772 const previouslyUnused = Array.from(this._unusedCssVariableDeclarations); 773 // Then compute the list of unused variables again 774 this._unusedCssVariableDeclarations = 775 this._getUnusedCssVariableDeclarations(); 776 777 for (const prop of previouslyUnused) { 778 if (this._unusedCssVariableDeclarations.has(prop)) { 779 continue; 780 } 781 782 // The prop wasn't used, but now is, so let's show it 783 this.showUnusedCssVariable(prop, { 784 updateButton: false, 785 }); 786 } 787 788 this._updateShowUnusedCustomCssPropertiesButtonText(); 789 } 790 791 /** 792 * Create a TextPropertyEditor for TextProperty representing an unused CSS variable. 793 * 794 * @param {TextProperty} prop 795 * @param {object} options 796 * @param {boolean} options.updateButton 797 * @returns {TextPropertyEditor|null} Returns null if passed TextProperty isn't found 798 * in the list of unused css variables 799 */ 800 showUnusedCssVariable(prop, { updateButton = true } = {}) { 801 if (prop.editor) { 802 return null; 803 } 804 805 this._unusedCssVariableDeclarations.delete(prop); 806 807 const editor = new TextPropertyEditor(this, prop, { 808 elementsWithPendingClicks: this.options.elementsWithPendingClicks, 809 }); 810 const declarationIndex = this.rule.textProps.indexOf(prop); 811 // We need to insert the editor according to its index in the list of declarations. 812 // So let's try to find the prop which is placed higher and is visible 813 let nextSibling; 814 for (let i = declarationIndex + 1; i < this.rule.textProps.length; i++) { 815 const currentProp = this.rule.textProps[i]; 816 if (currentProp.editor) { 817 nextSibling = currentProp.editor.element; 818 break; 819 } 820 } 821 // If we couldn't find nextSibling, that means that no declaration with higher index 822 // is visible, so we can put the newly visible property at the end 823 this.propertyList.insertBefore(editor.element, nextSibling || null); 824 825 if (updateButton) { 826 this._updateShowUnusedCustomCssPropertiesButtonText(); 827 } 828 829 return editor; 830 } 831 832 /** 833 * Returns a Set containing the list of unused CSS variable TextProperty which shouldn't 834 * be visible. 835 * 836 * @returns {Set<TextProperty>} 837 */ 838 _getUnusedCssVariableDeclarations() { 839 const unusedCssVariableDeclarations = new Set(); 840 841 // No need to go through the declarations if we shouldn't hide unused custom properties 842 if (!this.options.shouldHideUnusedCustomCssProperties) { 843 return unusedCssVariableDeclarations; 844 } 845 846 // Compute a list of variables that will be visible, as there might be unused variables 847 // that will be visible (e.g. if the user added one in the rules view) 848 for (const prop of this.rule.textProps) { 849 if (prop.isUnusedVariable) { 850 unusedCssVariableDeclarations.add(prop); 851 } 852 } 853 854 return unusedCssVariableDeclarations; 855 } 856 857 /** 858 * Handle click on "Show X unused custom CSS properties" button 859 * 860 * @param {Event} e 861 */ 862 _onShowUnusedCustomCssPropertiesButtonClick(e) { 863 e.stopPropagation(); 864 865 this._nullifyShowUnusedCustomCssProperties(); 866 867 for (const prop of this._unusedCssVariableDeclarations) { 868 if (!prop.invisible) { 869 const editor = new TextPropertyEditor(this, prop, { 870 elementsWithPendingClicks: this.options.elementsWithPendingClicks, 871 }); 872 // Insert at the original declaration index 873 this.propertyList.insertBefore( 874 editor.element, 875 this.propertyList.childNodes[this.rule.textProps.indexOf(prop)] || 876 null 877 ); 878 } 879 } 880 if (typeof this.options.onShowUnusedCustomCssProperties === "function") { 881 this.options.onShowUnusedCustomCssProperties(); 882 } 883 } 884 885 /** 886 * Update the text for the "Show X unused custom CSS properties" button, or remove it 887 * if there's no hidden custom properties anymore 888 */ 889 _updateShowUnusedCustomCssPropertiesButtonText() { 890 if (!this._showUnusedCustomCssPropertiesButton) { 891 return; 892 } 893 894 const unusedVariablesCount = this._unusedCssVariableDeclarations.size; 895 if (!unusedVariablesCount) { 896 this._nullifyShowUnusedCustomCssProperties(); 897 return; 898 } 899 900 const label = PluralForm.get( 901 unusedVariablesCount, 902 STYLE_INSPECTOR_L10N.getStr("rule.showUnusedCssVariable") 903 ).replace("#1", unusedVariablesCount); 904 905 this._showUnusedCustomCssPropertiesButton.replaceChildren(label); 906 } 907 908 /** 909 * Nullify this._showUnusedCustomCssPropertiesButton, remove its click event handler 910 * and remove it from the DOM if `removeFromDom` is set to true. 911 * 912 * @param {object} [options] 913 * @param {boolean} [options.removeFromDom] 914 * Should the button be removed from the DOM (defaults to true) 915 */ 916 _nullifyShowUnusedCustomCssProperties({ removeFromDom = true } = {}) { 917 if (!this._showUnusedCustomCssPropertiesButton) { 918 return; 919 } 920 921 this._showUnusedCustomCssPropertiesButton.removeEventListener( 922 "click", 923 this._onShowUnusedCustomCssPropertiesButtonClick 924 ); 925 926 if (removeFromDom) { 927 this._showUnusedCustomCssPropertiesButton.remove(); 928 } 929 this._showUnusedCustomCssPropertiesButton = null; 930 } 931 932 /** 933 * Render a given rule selector in this.selectorText element 934 * 935 * @param {string} selector: The selector text to display 936 * @param {number} selectorIndex: Its index in the rule 937 */ 938 _populateSelector(selector, selectorIndex) { 939 if (selectorIndex !== 0) { 940 createChild(this.selectorText, "span", { 941 class: "ruleview-selector-separator", 942 textContent: ", ", 943 }); 944 } 945 946 const containerClass = 947 "ruleview-selector " + 948 (this.rule.matchedSelectorIndexes.includes(selectorIndex) 949 ? "matched" 950 : "unmatched"); 951 952 let selectorContainerTitle; 953 if ( 954 typeof this.rule.selector.selectorsSpecificity?.[selectorIndex] !== 955 "undefined" 956 ) { 957 // The specificity that we get from the platform is a single number that we 958 // need to format into the common `(x,y,z)` specificity string. 959 const specificity = 960 this.rule.selector.selectorsSpecificity?.[selectorIndex]; 961 const a = Math.floor(specificity / (1024 * 1024)); 962 const b = Math.floor((specificity % (1024 * 1024)) / 1024); 963 const c = specificity % 1024; 964 selectorContainerTitle = STYLE_INSPECTOR_L10N.getFormatStr( 965 "rule.selectorSpecificity.title", 966 `(${a},${b},${c})` 967 ); 968 } 969 const selectorContainer = createChild(this.selectorText, "span", { 970 class: containerClass, 971 title: selectorContainerTitle, 972 }); 973 974 const parsedSelector = parsePseudoClassesAndAttributes(selector); 975 976 for (const selectorText of parsedSelector) { 977 let selectorClass = ""; 978 979 switch (selectorText.type) { 980 case SELECTOR_ATTRIBUTE: 981 selectorClass = "ruleview-selector-attribute"; 982 break; 983 case SELECTOR_ELEMENT: 984 selectorClass = "ruleview-selector-element"; 985 break; 986 case SELECTOR_PSEUDO_CLASS: 987 selectorClass = PSEUDO_CLASSES.some( 988 pseudo => selectorText.value === pseudo 989 ) 990 ? "ruleview-selector-pseudo-class-lock" 991 : "ruleview-selector-pseudo-class"; 992 break; 993 default: 994 break; 995 } 996 997 createChild(selectorContainer, "span", { 998 textContent: selectorText.value, 999 class: selectorClass, 1000 }); 1001 } 1002 1003 const warningsContainer = this._createWarningsElementForSelector( 1004 selectorIndex, 1005 this.rule.domRule.selectorWarnings 1006 ); 1007 if (warningsContainer) { 1008 selectorContainer.append(warningsContainer); 1009 } 1010 } 1011 1012 /** 1013 * Programatically add a new property to the rule. 1014 * 1015 * @param {string} name 1016 * Property name. 1017 * @param {string} value 1018 * Property value. 1019 * @param {string} priority 1020 * Property priority. 1021 * @param {boolean} enabled 1022 * True if the property should be enabled. 1023 * @param {TextProperty} siblingProp 1024 * Optional, property next to which the new property will be added. 1025 * @return {TextProperty} 1026 * The new property 1027 */ 1028 addProperty(name, value, priority, enabled, siblingProp) { 1029 const prop = this.rule.createProperty( 1030 name, 1031 value, 1032 priority, 1033 enabled, 1034 siblingProp 1035 ); 1036 const index = this.rule.textProps.indexOf(prop); 1037 const editor = new TextPropertyEditor(this, prop, { 1038 elementsWithPendingClicks: this.options.elementsWithPendingClicks, 1039 }); 1040 1041 // Insert this node before the DOM node that is currently at its new index 1042 // in the property list. There is currently one less node in the DOM than 1043 // in the property list, so this causes it to appear after siblingProp. 1044 // If there is no node at its index, as is the case where this is the last 1045 // node being inserted, then this behaves as appendChild. 1046 this.propertyList.insertBefore( 1047 editor.element, 1048 this.propertyList.children[index] 1049 ); 1050 1051 return prop; 1052 } 1053 1054 /** 1055 * Programatically add a list of new properties to the rule. Focus the UI 1056 * to the proper location after adding (either focus the value on the 1057 * last property if it is empty, or create a new property and focus it). 1058 * 1059 * @param {Array} properties 1060 * Array of properties, which are objects with this signature: 1061 * { 1062 * name: {string}, 1063 * value: {string}, 1064 * priority: {string} 1065 * } 1066 * @param {TextProperty} siblingProp 1067 * Optional, the property next to which all new props should be added. 1068 */ 1069 addProperties(properties, siblingProp) { 1070 if (!properties || !properties.length) { 1071 return; 1072 } 1073 1074 let lastProp = siblingProp; 1075 for (const p of properties) { 1076 const isCommented = Boolean(p.commentOffsets); 1077 const enabled = !isCommented; 1078 lastProp = this.addProperty( 1079 p.name, 1080 p.value, 1081 p.priority, 1082 enabled, 1083 lastProp 1084 ); 1085 } 1086 1087 // Either focus on the last value if incomplete, or start a new one. 1088 if (lastProp && lastProp.value.trim() === "") { 1089 lastProp.editor.valueSpan.click(); 1090 } else { 1091 this.newProperty(); 1092 } 1093 } 1094 1095 /** 1096 * Create a text input for a property name. If a non-empty property 1097 * name is given, we'll create a real TextProperty and add it to the 1098 * rule. 1099 */ 1100 newProperty() { 1101 // If we're already creating a new property, ignore this. 1102 if (!this.closeBrace.hasAttribute("tabindex")) { 1103 return; 1104 } 1105 1106 // While we're editing a new property, it doesn't make sense to start a second new 1107 // property editor, so disable focusing the close brace for now. 1108 this.closeBrace.removeAttribute("tabindex"); 1109 // We also need to make the "Show Unused Variables" button non-focusable so hitting 1110 // Tab while focused in the new property editor will move the focus to the next rule 1111 // selector editor. 1112 if (this._showUnusedCustomCssPropertiesButton) { 1113 this._showUnusedCustomCssPropertiesButton.setAttribute("tabindex", "-1"); 1114 } 1115 1116 this.newPropItem = createChild(this.propertyList, "div", { 1117 class: "ruleview-property ruleview-newproperty", 1118 role: "listitem", 1119 }); 1120 1121 this.newPropSpan = createChild(this.newPropItem, "span", { 1122 class: "ruleview-propertyname", 1123 tabindex: "0", 1124 }); 1125 1126 this.multipleAddedProperties = null; 1127 1128 this.editor = new InplaceEditor({ 1129 element: this.newPropSpan, 1130 done: this._onNewProperty, 1131 // (Shift+)Tab will move the focus to the previous/next editable field 1132 focusEditableFieldAfterApply: true, 1133 focusEditableFieldContainerSelector: ".ruleview-rule", 1134 destroy: this._newPropertyDestroy, 1135 advanceChars: ":", 1136 contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY, 1137 popup: this.ruleView.popup, 1138 cssProperties: this.rule.cssProperties, 1139 inputAriaLabel: NEW_PROPERTY_NAME_INPUT_LABEL, 1140 getCssVariables: () => 1141 this.rule.elementStyle.getAllCustomProperties(this.rule.pseudoElement), 1142 }); 1143 1144 // Auto-close the input if multiple rules get pasted into new property. 1145 this.editor.input.addEventListener( 1146 "paste", 1147 blurOnMultipleProperties(this.rule.cssProperties) 1148 ); 1149 } 1150 1151 /** 1152 * Called when the new property input has been dismissed. 1153 * 1154 * @param {string} value 1155 * The value in the editor. 1156 * @param {boolean} commit 1157 * True if the value should be committed. 1158 */ 1159 _onNewProperty(value, commit) { 1160 if (!value || !commit) { 1161 return; 1162 } 1163 1164 // parseDeclarations allows for name-less declarations, but in the present 1165 // case, we're creating a new declaration, it doesn't make sense to accept 1166 // these entries 1167 this.multipleAddedProperties = parseNamedDeclarations( 1168 this.rule.cssProperties.isKnown, 1169 value, 1170 true 1171 ); 1172 1173 // Blur the editor field now and deal with adding declarations later when 1174 // the field gets destroyed (see _newPropertyDestroy) 1175 this.editor.input.blur(); 1176 1177 this.telemetry.recordEvent("edit_rule", "ruleview"); 1178 } 1179 1180 /** 1181 * Called when the new property editor is destroyed. 1182 * This is where the properties (type TextProperty) are actually being 1183 * added, since we want to wait until after the inplace editor `destroy` 1184 * event has been fired to keep consistent UI state. 1185 */ 1186 _newPropertyDestroy() { 1187 // We're done, make the close brace and "Show unused variable" button focusable again. 1188 this.closeBrace.setAttribute("tabindex", "0"); 1189 if (this._showUnusedCustomCssPropertiesButton) { 1190 this._showUnusedCustomCssPropertiesButton.removeAttribute("tabindex"); 1191 } 1192 1193 this.propertyList.removeChild(this.newPropItem); 1194 delete this.newPropItem; 1195 delete this.newPropSpan; 1196 1197 // If properties were added, we want to focus the proper element. 1198 // If the last new property has no value, focus the value on it. 1199 // Otherwise, start a new property and focus that field. 1200 if (this.multipleAddedProperties && this.multipleAddedProperties.length) { 1201 this.addProperties(this.multipleAddedProperties); 1202 } 1203 } 1204 1205 /** 1206 * Called when the selector's inplace editor is closed. 1207 * Ignores the change if the user pressed escape, otherwise 1208 * commits it. 1209 * 1210 * @param {string} value 1211 * The value contained in the editor. 1212 * @param {boolean} commit 1213 * True if the change should be applied. 1214 * @param {number} direction 1215 * The move focus direction number. 1216 */ 1217 async _onSelectorDone(value, commit, direction) { 1218 if ( 1219 !commit || 1220 this.isEditing || 1221 value === "" || 1222 value === this.rule.selectorText 1223 ) { 1224 return; 1225 } 1226 1227 const ruleView = this.ruleView; 1228 const elementStyle = ruleView._elementStyle; 1229 const element = elementStyle.element; 1230 1231 this.isEditing = true; 1232 1233 // Remove highlighter for the previous selector. 1234 const computedSelector = this.rule.domRule.computedSelector; 1235 if (this.ruleView.isSelectorHighlighted(computedSelector)) { 1236 await this.ruleView.toggleSelectorHighlighter( 1237 this.rule, 1238 computedSelector 1239 ); 1240 } 1241 1242 try { 1243 const response = await this.rule.domRule.modifySelector(element, value); 1244 1245 // Modifying the selector might have removed the element (e.g. for pseudo element) 1246 if (!element.actorID) { 1247 return; 1248 } 1249 1250 // We recompute the list of applied styles, because editing a 1251 // selector might cause this rule's position to change. 1252 const applied = await elementStyle.pageStyle.getApplied(element, { 1253 inherited: true, 1254 matchedSelectors: true, 1255 filter: elementStyle.showUserAgentStyles ? "ua" : undefined, 1256 }); 1257 1258 // The element might have been removed while we were trying to get the applied declarations 1259 if (!element.actorID) { 1260 return; 1261 } 1262 1263 this.isEditing = false; 1264 1265 const { ruleProps, isMatching } = response; 1266 if (!ruleProps) { 1267 // Notify for changes, even when nothing changes, 1268 // just to allow tests being able to track end of this request. 1269 ruleView.emit("ruleview-invalid-selector"); 1270 return; 1271 } 1272 1273 ruleProps.isUnmatched = !isMatching; 1274 const newRule = new Rule(elementStyle, ruleProps); 1275 const editor = new RuleEditor(ruleView, newRule); 1276 const rules = elementStyle.rules; 1277 1278 let newRuleIndex = applied.findIndex(r => r.rule == ruleProps.rule); 1279 const oldIndex = rules.indexOf(this.rule); 1280 1281 // If the selector no longer matches, then we leave the rule in 1282 // the same relative position. 1283 if (newRuleIndex === -1) { 1284 newRuleIndex = oldIndex; 1285 } 1286 1287 // Remove the old rule and insert the new rule. 1288 rules.splice(oldIndex, 1); 1289 rules.splice(newRuleIndex, 0, newRule); 1290 elementStyle._changed(); 1291 elementStyle.onRuleUpdated(); 1292 1293 // We install the new editor in place of the old -- you might 1294 // think we would replicate the list-modification logic above, 1295 // but that is complicated due to the way the UI installs 1296 // pseudo-element rules and the like. 1297 this.element.parentNode.replaceChild(editor.element, this.element); 1298 1299 // As the rules elements will be replaced, and given that the inplace-editor doesn't 1300 // wait for this `done` callback to be resolved, the focus management we do there 1301 // will be useless as this specific code will usually happen later (and the focused 1302 // element might be replaced). 1303 // Because of this, we need to handle setting the focus ourselves from here. 1304 editor._moveSelectorFocus(direction); 1305 } catch (err) { 1306 this.isEditing = false; 1307 promiseWarn(err); 1308 } 1309 } 1310 1311 /** 1312 * Handle moving the focus change after a Tab keypress in the selector inplace editor. 1313 * 1314 * @param {number} direction 1315 * The move focus direction number. 1316 */ 1317 _moveSelectorFocus(direction) { 1318 if (!direction || direction === Services.focus.MOVEFOCUS_BACKWARD) { 1319 return; 1320 } 1321 1322 if (this.rule.textProps.length) { 1323 this.rule.textProps[0].editor.nameSpan.click(); 1324 } else { 1325 this.propertyList.click(); 1326 } 1327 } 1328 } 1329 1330 module.exports = RuleEditor;