element-style.js (37880B)
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 Rule = require("resource://devtools/client/inspector/rules/models/rule.js"); 8 const UserProperties = require("resource://devtools/client/inspector/rules/models/user-properties.js"); 9 const { 10 style: { ELEMENT_STYLE, PRES_HINTS }, 11 } = require("resource://devtools/shared/constants.js"); 12 13 loader.lazyRequireGetter( 14 this, 15 "promiseWarn", 16 "resource://devtools/client/inspector/shared/utils.js", 17 true 18 ); 19 loader.lazyRequireGetter( 20 this, 21 ["parseDeclarations", "parseNamedDeclarations", "parseSingleValue"], 22 "resource://devtools/shared/css/parsing-utils.js", 23 true 24 ); 25 loader.lazyRequireGetter( 26 this, 27 "isCssVariable", 28 "resource://devtools/shared/inspector/css-logic.js", 29 true 30 ); 31 32 /** 33 * ElementStyle is responsible for the following: 34 * Keeps track of which properties are overridden. 35 * Maintains a list of Rule objects for a given element. 36 */ 37 class ElementStyle { 38 /** 39 * @param {Element} element 40 * The element whose style we are viewing. 41 * @param {CssRuleView} ruleView 42 * The instance of the rule-view panel. 43 * @param {object} store 44 * The ElementStyle can use this object to store metadata 45 * that might outlast the rule view, particularly the current 46 * set of disabled properties. 47 * @param {PageStyleFront} pageStyle 48 * Front for the page style actor that will be providing 49 * the style information. 50 * @param {boolean} showUserAgentStyles 51 * Should user agent styles be inspected? 52 */ 53 constructor(element, ruleView, store, pageStyle, showUserAgentStyles) { 54 this.element = element; 55 this.ruleView = ruleView; 56 this.store = store || {}; 57 this.pageStyle = pageStyle; 58 this.pseudoElementTypes = new Set(); 59 this.showUserAgentStyles = showUserAgentStyles; 60 this.rules = []; 61 this.cssProperties = this.ruleView.cssProperties; 62 this.variablesMap = new Map(); 63 this.startingStyleVariablesMap = new Map(); 64 65 // We don't want to overwrite this.store.userProperties so we only create it 66 // if it doesn't already exist. 67 if (!("userProperties" in this.store)) { 68 this.store.userProperties = new UserProperties(); 69 } 70 71 if (!("disabled" in this.store)) { 72 this.store.disabled = new WeakMap(); 73 } 74 } 75 76 destroy() { 77 if (this.destroyed) { 78 return; 79 } 80 81 this.destroyed = true; 82 this.pseudoElementTypes.clear(); 83 84 for (const rule of this.rules) { 85 if (rule.editor) { 86 rule.editor.destroy(); 87 } 88 89 rule.destroy(); 90 } 91 } 92 93 /** 94 * Called by the Rule object when it has been changed through the 95 * setProperty* methods. 96 */ 97 _changed() { 98 if (this.onChanged) { 99 this.onChanged(); 100 } 101 } 102 103 /** 104 * Refresh the list of rules to be displayed for the active element. 105 * Upon completion, this.rules[] will hold a list of Rule objects. 106 * 107 * Returns a promise that will be resolved when the elementStyle is 108 * ready. 109 */ 110 populate() { 111 const populated = this.pageStyle 112 .getApplied(this.element, { 113 inherited: true, 114 matchedSelectors: true, 115 filter: this.showUserAgentStyles ? "ua" : undefined, 116 }) 117 .then(entries => { 118 if (this.destroyed || this.populated !== populated) { 119 return Promise.resolve(undefined); 120 } 121 122 // Store the current list of rules (if any) during the population 123 // process. They will be reused if possible. 124 const existingRules = this.rules; 125 126 this.rules = []; 127 128 for (const entry of entries) { 129 this._maybeAddRule(entry, existingRules); 130 } 131 132 // Store a list of all (non-inherited) pseudo-element types found in the matching rules. 133 this.pseudoElementTypes = new Set(); 134 for (const rule of this.rules) { 135 if (rule.pseudoElement && !rule.inherited) { 136 this.pseudoElementTypes.add(rule.pseudoElement); 137 } 138 } 139 140 // Mark overridden computed styles. 141 this.onRuleUpdated(); 142 143 this._sortRulesForPseudoElement(); 144 145 // We're done with the previous list of rules. 146 for (const r of existingRules) { 147 if (r?.editor) { 148 r.editor.destroy(); 149 } 150 151 r.destroy(); 152 } 153 154 return undefined; 155 }) 156 .catch(e => { 157 // populate is often called after a setTimeout, 158 // the connection may already be closed. 159 if (this.destroyed) { 160 return Promise.resolve(undefined); 161 } 162 return promiseWarn(e); 163 }); 164 this.populated = populated; 165 return this.populated; 166 } 167 168 /** 169 * Returns the Rule object of the given rule id. 170 * 171 * @param {string | null} id 172 * The id of the Rule object. 173 * @return {Rule|undefined} of the given rule id or undefined if it cannot be found. 174 */ 175 getRule(id) { 176 return id 177 ? this.rules.find(rule => rule.domRule.actorID === id) 178 : undefined; 179 } 180 181 /** 182 * Get the font families in use by the element. 183 * 184 * Returns a promise that will be resolved to a Set of lowercased CSS family names. 185 */ 186 getUsedFontFamilies() { 187 return new Promise((resolve, reject) => { 188 this.ruleView.styleWindow.requestIdleCallback(async () => { 189 if (this.element.isDestroyed()) { 190 resolve([]); 191 return; 192 } 193 try { 194 const fonts = await this.pageStyle.getUsedFontFaces(this.element, { 195 includePreviews: false, 196 }); 197 const familyNames = new Set(); 198 for (const font of fonts) { 199 if (font.CSSFamilyName) { 200 familyNames.add(font.CSSFamilyName.toLowerCase()); 201 } 202 203 // CSSGeneric is the font generic name (e.g. system-ui), which is different 204 // from the CSSFamilyName but can also be used as a font-family (e.g. for 205 // system-ui, the actual font name is ".SF NS" on OSX 14.6). 206 if (font.CSSGeneric) { 207 familyNames.add(font.CSSGeneric.toLowerCase()); 208 } 209 } 210 resolve(familyNames); 211 } catch (e) { 212 reject(e); 213 } 214 }); 215 }); 216 } 217 218 /** 219 * Put non inherited pseudo elements in front of others rules. 220 */ 221 _sortRulesForPseudoElement() { 222 this.rules = this.rules.sort((a, b) => { 223 if ( 224 !a.inherited === !b.inherited && 225 !!a.pseudoElement !== !!b.pseudoElement 226 ) { 227 return (a.pseudoElement || "z") > (b.pseudoElement || "z") ? 1 : -1; 228 } 229 return 0; 230 }); 231 } 232 233 /** 234 * Add a rule if it's one we care about. Filters out duplicates and 235 * inherited styles with no inherited properties. 236 * 237 * @param {object} options 238 * Options for creating the Rule, see the Rule constructor. 239 * @param {Array} existingRules 240 * Rules to reuse if possible. If a rule is reused, then it 241 * it will be deleted from this array. 242 * @return {boolean} true if we added the rule. 243 */ 244 _maybeAddRule(options, existingRules) { 245 // If we've already included this domRule (for example, when a 246 // common selector is inherited), ignore it. 247 if ( 248 options.system || 249 (options.rule && this.rules.some(rule => rule.domRule === options.rule)) 250 ) { 251 return false; 252 } 253 254 let rule = null; 255 256 // If we're refreshing and the rule previously existed, reuse the 257 // Rule object. 258 if (existingRules) { 259 const ruleIndex = existingRules.findIndex(r => r.matches(options)); 260 if (ruleIndex >= 0) { 261 rule = existingRules[ruleIndex]; 262 rule.refresh(options); 263 existingRules.splice(ruleIndex, 1); 264 } 265 } 266 267 // If this is a new rule, create its Rule object. 268 if (!rule) { 269 rule = new Rule(this, options); 270 } 271 272 // Ignore inherited rules with no visible properties. 273 if (options.inherited && !rule.hasAnyVisibleProperties()) { 274 return false; 275 } 276 277 this.rules.push(rule); 278 return true; 279 } 280 281 /** 282 * Calls updateDeclarations with all supported pseudo elements 283 */ 284 onRuleUpdated() { 285 this.updateDeclarations(); 286 287 // Update declarations for matching rules for pseudo-elements. 288 for (const pseudo of this.pseudoElementTypes) { 289 this.updateDeclarations(pseudo); 290 } 291 } 292 293 /** 294 * Go over all CSS rules matching the selected element and mark the CSS declarations 295 * (aka TextProperty instances) with an `overridden` Boolean flag if an earlier or 296 * higher priority declaration overrides it. Rules are already ordered by specificity. 297 * 298 * If a pseudo-element type is passed (ex: ::before, ::first-line, etc), 299 * restrict the operation only to declarations in rules matching that pseudo-element. 300 * 301 * At the end, update the declaration's view (TextPropertyEditor instance) so it relects 302 * the latest state. Use this opportunity to also trigger checks for the "inactive" 303 * state of the declaration (whether it has effect or not). 304 * 305 * @param {string} pseudo 306 * Optional pseudo-element for which to restrict marking CSS declarations as 307 * overridden. 308 */ 309 // eslint-disable-next-line complexity 310 updateDeclarations(pseudo = "") { 311 // Gather all text properties applicable to the selected element or pseudo-element. 312 const textProps = this._getDeclarations(pseudo); 313 314 // CSS Variables inherits from the normal element in case of pseudo element. 315 const variables = new Map(pseudo ? this.variablesMap.get("") : null); 316 const startingStyleVariables = new Map( 317 pseudo ? this.startingStyleVariablesMap.get("") : null 318 ); 319 320 // Walk over the computed properties. As we see a property name 321 // for the first time, mark that property's name as taken by this 322 // property. 323 // 324 // If we come across a property whose name is already taken, check 325 // its priority against the property that was found first: 326 // 327 // If the new property is a higher priority, mark the old 328 // property overridden and mark the property name as taken by 329 // the new property. 330 // 331 // If the new property is a lower or equal priority, mark it as 332 // overridden. 333 // 334 // Note that this is different if layers are involved: if both 335 // old and new properties have a high priority, and if the new 336 // property is in a rule belonging to a layer that is different 337 // from the the one the old property rule might be in, 338 // mark the old property overridden and mark the property name as 339 // taken by the new property. 340 // 341 // _overriddenDirty will be set on each prop, indicating whether its 342 // dirty status changed during this pass. 343 const taken = new Map(); 344 const takenInStartingStyle = new Map(); 345 for (const textProp of textProps) { 346 for (const computedProp of textProp.computed) { 347 const earlier = taken.get(computedProp.name); 348 const earlierInStartingStyle = takenInStartingStyle.get( 349 computedProp.name 350 ); 351 352 // Prevent -webkit-gradient from being selected after unchecking 353 // linear-gradient in this case: 354 // -moz-linear-gradient: ...; 355 // -webkit-linear-gradient: ...; 356 // linear-gradient: ...; 357 if (!computedProp.textProp.isValid()) { 358 continue; 359 } 360 361 const isPropInStartingStyle = 362 computedProp.textProp.rule?.isInStartingStyle(); 363 364 const hasHigherPriority = this._hasHigherPriorityThanEarlierProp( 365 computedProp, 366 earlier 367 ); 368 const startingStyleHasHigherPriority = 369 this._hasHigherPriorityThanEarlierProp( 370 computedProp, 371 earlierInStartingStyle 372 ); 373 374 // earlier prop is overridden if the new property has higher priority and is not 375 // in a starting style rule. 376 if (hasHigherPriority && !isPropInStartingStyle) { 377 // New property is higher priority. Mark the earlier property 378 // overridden (which will reverse its dirty state). 379 earlier._overriddenDirty = !earlier._overriddenDirty; 380 earlier.overridden = true; 381 } 382 383 // earlier starting-style prop are always going to be overriden if the new property 384 // has higher priority 385 if (startingStyleHasHigherPriority) { 386 earlierInStartingStyle._overriddenDirty = 387 !earlierInStartingStyle._overriddenDirty; 388 earlierInStartingStyle.overridden = true; 389 // which means we also need to remove the variable from startingStyleVariables 390 if (isCssVariable(computedProp.name)) { 391 startingStyleVariables.delete(computedProp.name); 392 } 393 } 394 395 // This computed property is overridden if: 396 // - there was an earlier prop and this one does not have higher priority 397 // - or if this is a starting-style prop, and there was an earlier starting-style 398 // prop, and this one hasn't higher priority. 399 const overridden = 400 (!!earlier && !hasHigherPriority) || 401 (isPropInStartingStyle && 402 !!earlierInStartingStyle && 403 !startingStyleHasHigherPriority); 404 405 computedProp._overriddenDirty = 406 !!computedProp.overridden !== overridden; 407 computedProp.overridden = overridden; 408 409 if (!computedProp.overridden && computedProp.textProp.enabled) { 410 if (isPropInStartingStyle) { 411 takenInStartingStyle.set(computedProp.name, computedProp); 412 } else { 413 taken.set(computedProp.name, computedProp); 414 } 415 416 // At this point, we can get CSS variable from "inherited" rules. 417 // When this is a registered custom property with `inherits` set to false, 418 // the text prop is "invisible" (i.e. not shown in the rule view). 419 // In such case, we don't want to get the value in the Map, and we'll rather 420 // get the initial value from the registered property definition. 421 if ( 422 isCssVariable(computedProp.name) && 423 !computedProp.textProp.invisible 424 ) { 425 if (!isPropInStartingStyle) { 426 variables.set(computedProp.name, { 427 declarationValue: computedProp.value, 428 computedValue: computedProp.textProp.getVariableComputedValue(), 429 }); 430 } else { 431 startingStyleVariables.set(computedProp.name, computedProp.value); 432 } 433 } 434 } 435 } 436 } 437 438 // Find the CSS variables that have been updated. 439 const previousVariablesMap = new Map(this.variablesMap.get(pseudo)); 440 const changedVariableNamesSet = new Set( 441 [...variables.keys(), ...previousVariablesMap.keys()].filter( 442 k => variables.get(k) !== previousVariablesMap.get(k) 443 ) 444 ); 445 const previousStartingStyleVariablesMap = new Map( 446 this.startingStyleVariablesMap.get(pseudo) 447 ); 448 const changedStartingStyleVariableNamesSet = new Set( 449 [...variables.keys(), ...previousStartingStyleVariablesMap.keys()].filter( 450 k => variables.get(k) !== previousStartingStyleVariablesMap.get(k) 451 ) 452 ); 453 454 this.variablesMap.set(pseudo, variables); 455 this.startingStyleVariablesMap.set(pseudo, startingStyleVariables); 456 457 const rulesEditors = new Set(); 458 const variableTree = new Map(); 459 460 if (!this.usedVariables) { 461 this.usedVariables = new Set(); 462 } else { 463 this.usedVariables.clear(); 464 } 465 466 // For each TextProperty, mark it overridden if all of its computed 467 // properties are marked overridden. Update the text property's associated 468 // editor, if any. This will clear the _overriddenDirty state on all 469 // computed properties. For each editor we also show or hide the inactive 470 // CSS icon as needed. 471 for (const textProp of textProps) { 472 // _updatePropertyOverridden will return true if the 473 // overridden state has changed for the text property. 474 // _hasUpdatedCSSVariable will return true if the declaration contains any 475 // of the updated CSS variable names. 476 if ( 477 this._updatePropertyOverridden(textProp) || 478 this._hasUpdatedCSSVariable(textProp, changedVariableNamesSet) || 479 this._hasUpdatedCSSVariable( 480 textProp, 481 changedStartingStyleVariableNamesSet 482 ) 483 ) { 484 textProp.updateEditor(); 485 } 486 487 // For each editor show or hide the inactive CSS icon as needed. 488 if (textProp.editor) { 489 textProp.editor.updateUI(); 490 } 491 492 // First we need to update used variables from all declarations 493 textProp.updateUsedVariables(); 494 const isCustomProperty = textProp.name.startsWith("--"); 495 const isNewCustomProperty = 496 isCustomProperty && textProp.isPropertyChanged; 497 if (isNewCustomProperty) { 498 this.usedVariables.add(textProp.name); 499 } 500 if (textProp.usedVariables) { 501 if (!isCustomProperty) { 502 for (const variable of textProp.usedVariables) { 503 this.usedVariables.add(variable); 504 } 505 } else { 506 variableTree.set(textProp.name, textProp.usedVariables); 507 } 508 } 509 510 if (textProp.rule.editor) { 511 rulesEditors.add(textProp.rule.editor); 512 } 513 } 514 515 const collectVariableDependencies = variable => { 516 if (!variableTree.has(variable)) { 517 return; 518 } 519 520 for (const dep of variableTree.get(variable)) { 521 if (!this.usedVariables.has(dep)) { 522 this.usedVariables.add(dep); 523 collectVariableDependencies(dep); 524 } 525 } 526 }; 527 528 for (const variable of this.usedVariables) { 529 collectVariableDependencies(variable); 530 } 531 532 for (const textProp of textProps) { 533 // Then we need to update the isUnusedCssVariable 534 textProp.updateIsUnusedVariable(); 535 } 536 537 // Then update the UI 538 for (const ruleEditor of rulesEditors) { 539 ruleEditor.updateUnusedCssVariables(); 540 } 541 } 542 543 /** 544 * Return whether or not the passed computed property has a higher priority than 545 * a computed property seen "earlier" (e.g. whose rule had higher priority, or that 546 * was declared in the same rule, but earlier). 547 * 548 * @param {object} computedProp: A computed prop object, as stored in TextProp#computed 549 * @param {object} earlierProp: The computed prop to compare against 550 * @returns Boolean 551 */ 552 _hasHigherPriorityThanEarlierProp(computedProp, earlierProp) { 553 if (!earlierProp) { 554 return false; 555 } 556 557 if (computedProp.priority !== "important") { 558 return false; 559 } 560 561 const rule = computedProp.textProp.rule; 562 const earlierRule = earlierProp.textProp.rule; 563 564 // for only consider rules applying to the same node. 565 if (rule.inherited !== earlierRule.inherited) { 566 return false; 567 } 568 569 // only consider rules applying on the same (inherited) pseudo element (e.g. ::details-content), 570 // or rules both not applying to pseudo elements 571 if (rule.pseudoElement !== earlierRule.pseudoElement) { 572 return false; 573 } 574 575 // At this point, the computed prop is important, and it applies to the same element 576 // (or pseudo element) than the earlier prop. 577 return ( 578 earlierProp.priority !== "important" || 579 // Even if the earlier property was important, if the current rule is in a layer 580 // it will take precedence, unless the earlier property rule was in the same layer… 581 (rule?.isInLayer() && 582 rule.isInDifferentLayer(earlierRule) && 583 // … or if the earlier declaration is in the style attribute (https://www.w3.org/TR/css-cascade-5/#style-attr). 584 earlierRule.domRule.type !== ELEMENT_STYLE) 585 ); 586 } 587 588 /** 589 * Update CSS variable tooltip information on textProp editor when registered property 590 * are added/modified/removed. 591 * 592 * @param {Set<string>} registeredPropertyNamesSet: A Set containing the name of the 593 * registered properties which were added/modified/removed. 594 */ 595 onRegisteredPropertiesChange(registeredPropertyNamesSet) { 596 for (const rule of this.rules) { 597 for (const textProp of rule.textProps) { 598 if (this._hasUpdatedCSSVariable(textProp, registeredPropertyNamesSet)) { 599 textProp.updateEditor(); 600 } 601 } 602 } 603 } 604 605 /** 606 * Returns true if the given declaration's property value contains a CSS variable 607 * matching any of the updated CSS variable names. 608 * 609 * @param {TextProperty} declaration 610 * A TextProperty of a rule. 611 * @param {Set<string>} variableNamesSet 612 * A Set of CSS variable names that have been updated. 613 */ 614 _hasUpdatedCSSVariable(declaration, variableNamesSet) { 615 if (variableNamesSet.size === 0) { 616 return false; 617 } 618 619 return !variableNamesSet.isDisjointFrom(declaration.usedVariables); 620 } 621 622 /** 623 * Helper for |this.updateDeclarations()| to mark CSS declarations as overridden. 624 * 625 * Returns an array of CSS declarations (aka TextProperty instances) from all rules 626 * applicable to the selected element ordered from more- to less-specific. 627 * 628 * If a pseudo-element type is given, restrict the result only to declarations 629 * applicable to that pseudo-element. 630 * 631 * NOTE: this method skips CSS declarations in @keyframes rules because a number of 632 * criteria such as time and animation delay need to be checked in order to determine 633 * if the property is overridden at runtime. 634 * 635 * @param {string} pseudo 636 * Optional pseudo-element for which to restrict marking CSS declarations as 637 * overridden. If omitted, only declarations for regular style rules are 638 * returned (no pseudo-element style rules). 639 * 640 * @return {Array} 641 * Array of TextProperty instances. 642 */ 643 _getDeclarations(pseudo = "") { 644 const textProps = []; 645 646 for (const rule of this.rules) { 647 // Skip @keyframes rules 648 if (rule.keyframes) { 649 continue; 650 } 651 652 const isNestedDeclarations = rule.domRule.isNestedDeclarations; 653 const isInherited = !!rule.inherited; 654 655 // Style rules must be considered only when they have selectors that match the node. 656 // When renaming a selector, the unmatched rule lingers in the Rule view, but it no 657 // longer matches the node. This strict check avoids accidentally causing 658 // declarations to be overridden in the remaining matching rules. 659 const isStyleRule = 660 rule.pseudoElement === "" && rule.matchedSelectorIndexes.length; 661 662 // Style rules for pseudo-elements must always be considered, regardless if their 663 // selector matches the node. As a convenience, declarations in rules for 664 // pseudo-elements show up in a separate Pseudo-elements accordion when selecting 665 // the host node (instead of the pseudo-element node directly, which is sometimes 666 // impossible, for example with ::selection or ::first-line). 667 // Loosening the strict check on matched selectors ensures these declarations 668 // participate in the algorithm below to mark them as overridden. 669 const isMatchingPseudoElementRule = 670 rule.pseudoElement !== "" && 671 rule.pseudoElement === pseudo && 672 // Inherited pseudo element rules don't appear in the "Pseudo elements" section, 673 // so they should be considered style rules. 674 !isInherited; 675 const isInheritedPseudoElementRule = 676 rule.pseudoElement !== "" && isInherited; 677 678 const isElementStyle = rule.domRule.type === ELEMENT_STYLE; 679 const isElementAttributesStyle = rule.domRule.type === PRES_HINTS; 680 681 const filterCondition = 682 isNestedDeclarations || 683 (pseudo && isMatchingPseudoElementRule) || 684 (pseudo === "" && 685 (isStyleRule || 686 isElementStyle || 687 isElementAttributesStyle || 688 isInheritedPseudoElementRule)); 689 690 // Collect all relevant CSS declarations (aka TextProperty instances). 691 if (filterCondition) { 692 for (const textProp of rule.textProps.toReversed()) { 693 if (textProp.enabled) { 694 textProps.push(textProp); 695 } 696 } 697 } 698 } 699 700 return textProps; 701 } 702 703 /** 704 * Adds a new declaration to the rule. 705 * 706 * @param {string} ruleId 707 * The id of the Rule to be modified. 708 * @param {string} value 709 * The new declaration value. 710 */ 711 addNewDeclaration(ruleId, value) { 712 const rule = this.getRule(ruleId); 713 if (!rule) { 714 return; 715 } 716 717 const declarationsToAdd = parseNamedDeclarations( 718 this.cssProperties.isKnown, 719 value, 720 true 721 ); 722 if (!declarationsToAdd.length) { 723 return; 724 } 725 726 this._addMultipleDeclarations(rule, declarationsToAdd); 727 } 728 729 /** 730 * Adds a new rule. The rules view is updated from a "stylesheet-updated" event 731 * emitted the PageStyleActor as a result of the rule being inserted into the 732 * the stylesheet. 733 */ 734 async addNewRule() { 735 await this.pageStyle.addNewRule( 736 this.element, 737 this.element.pseudoClassLocks 738 ); 739 } 740 741 /** 742 * Given the id of the rule and the new declaration name, modifies the existing 743 * declaration name to the new given value. 744 * 745 * @param {string} ruleId 746 * The Rule id of the given CSS declaration. 747 * @param {string} declarationId 748 * The TextProperty id for the CSS declaration. 749 * @param {string} name 750 * The new declaration name. 751 */ 752 async modifyDeclarationName(ruleId, declarationId, name) { 753 const rule = this.getRule(ruleId); 754 if (!rule) { 755 return; 756 } 757 758 const declaration = rule.getDeclaration(declarationId); 759 if (!declaration || declaration.name === name) { 760 return; 761 } 762 763 // Adding multiple rules inside of name field overwrites the current 764 // property with the first, then adds any more onto the property list. 765 const declarations = parseDeclarations(this.cssProperties.isKnown, name); 766 if (!declarations.length) { 767 return; 768 } 769 770 await declaration.setName(declarations[0].name); 771 772 if (!declaration.enabled) { 773 await declaration.setEnabled(true); 774 } 775 } 776 777 /** 778 * Helper function to addNewDeclaration() and modifyDeclarationValue() for 779 * adding multiple declarations to a rule. 780 * 781 * @param {Rule} rule 782 * The Rule object to write new declarations to. 783 * @param {Array<object>} declarationsToAdd 784 * An array of object containg the parsed declaration data to be added. 785 * @param {TextProperty|null} siblingDeclaration 786 * Optional declaration next to which the new declaration will be added. 787 */ 788 _addMultipleDeclarations(rule, declarationsToAdd, siblingDeclaration = null) { 789 for (const { commentOffsets, name, value, priority } of declarationsToAdd) { 790 const isCommented = Boolean(commentOffsets); 791 const enabled = !isCommented; 792 siblingDeclaration = rule.createProperty( 793 name, 794 value, 795 priority, 796 enabled, 797 siblingDeclaration 798 ); 799 } 800 } 801 802 /** 803 * Parse a value string and break it into pieces, starting with the 804 * first value, and into an array of additional declarations (if any). 805 * 806 * Example: Calling with "red; width: 100px" would return 807 * { firstValue: "red", propertiesToAdd: [{ name: "width", value: "100px" }] } 808 * 809 * @param {string} value 810 * The string to parse. 811 * @return {object} An object with the following properties: 812 * firstValue: A string containing a simple value, like 813 * "red" or "100px!important" 814 * declarationsToAdd: An array with additional declarations, following the 815 * parseDeclarations format of { name, value, priority } 816 */ 817 _getValueAndExtraProperties(value) { 818 // The inplace editor will prevent manual typing of multiple declarations, 819 // but we need to deal with the case during a paste event. 820 // Adding multiple declarations inside of value editor sets value with the 821 // first, then adds any more onto the declaration list (below this declarations). 822 let firstValue = value; 823 let declarationsToAdd = []; 824 825 const declarations = parseDeclarations(this.cssProperties.isKnown, value); 826 827 // Check to see if the input string can be parsed as multiple declarations 828 if (declarations.length) { 829 // Get the first property value (if any), and any remaining 830 // declarations (if any) 831 if (!declarations[0].name && declarations[0].value) { 832 firstValue = declarations[0].value; 833 declarationsToAdd = declarations.slice(1); 834 } else if (declarations[0].name && declarations[0].value) { 835 // In some cases, the value could be a property:value pair 836 // itself. Join them as one value string and append 837 // potentially following declarations 838 firstValue = declarations[0].name + ": " + declarations[0].value; 839 declarationsToAdd = declarations.slice(1); 840 } 841 } 842 843 return { 844 declarationsToAdd, 845 firstValue, 846 }; 847 } 848 849 /** 850 * Given the id of the rule and the new declaration value, modifies the existing 851 * declaration value to the new given value. 852 * 853 * @param {string} ruleId 854 * The Rule id of the given CSS declaration. 855 * @param {string} declarationId 856 * The TextProperty id for the CSS declaration. 857 * @param {string} value 858 * The new declaration value. 859 */ 860 async modifyDeclarationValue(ruleId, declarationId, value) { 861 const rule = this.getRule(ruleId); 862 if (!rule) { 863 return; 864 } 865 866 const declaration = rule.getDeclaration(declarationId); 867 if (!declaration) { 868 return; 869 } 870 871 const { declarationsToAdd, firstValue } = 872 this._getValueAndExtraProperties(value); 873 const parsedValue = parseSingleValue( 874 this.cssProperties.isKnown, 875 firstValue 876 ); 877 878 if ( 879 !declarationsToAdd.length && 880 declaration.value === parsedValue.value && 881 declaration.priority === parsedValue.priority 882 ) { 883 return; 884 } 885 886 // First, set this declaration value (common case, only modified a property) 887 await declaration.setValue(parsedValue.value, parsedValue.priority); 888 889 if (!declaration.enabled) { 890 await declaration.setEnabled(true); 891 } 892 893 this._addMultipleDeclarations(rule, declarationsToAdd, declaration); 894 } 895 896 /** 897 * Modifies the existing rule's selector to the new given value. 898 * 899 * @param {string} ruleId 900 * The id of the Rule to be modified. 901 * @param {string} selector 902 * The new selector value. 903 */ 904 async modifySelector(ruleId, selector) { 905 try { 906 const rule = this.getRule(ruleId); 907 if (!rule) { 908 return; 909 } 910 911 const response = await rule.domRule.modifySelector( 912 this.element, 913 selector 914 ); 915 const { ruleProps, isMatching } = response; 916 917 if (!ruleProps) { 918 // Notify for changes, even when nothing changes, just to allow tests 919 // being able to track end of this request. 920 this.ruleView.emit("ruleview-invalid-selector"); 921 return; 922 } 923 924 const newRule = new Rule(this, { 925 ...ruleProps, 926 isUnmatched: !isMatching, 927 }); 928 929 // Recompute the list of applied styles because editing a 930 // selector might cause this rule's position to change. 931 const appliedStyles = await this.pageStyle.getApplied(this.element, { 932 inherited: true, 933 matchedSelectors: true, 934 filter: this.showUserAgentStyles ? "ua" : undefined, 935 }); 936 const newIndex = appliedStyles.findIndex(r => r.rule == ruleProps.rule); 937 const oldIndex = this.rules.indexOf(rule); 938 939 // Remove the old rule and insert the new rule according to where it appears 940 // in the list of applied styles. 941 this.rules.splice(oldIndex, 1); 942 // If the selector no longer matches, then we leave the rule in 943 // the same relative position. 944 this.rules.splice(newIndex === -1 ? oldIndex : newIndex, 0, newRule); 945 946 // Recompute, mark and update the UI for any properties that are 947 // overridden or contain inactive CSS according to the new list of rules. 948 this.onRuleUpdated(); 949 950 // In order to keep the new rule in place of the old in the rules view, we need 951 // to remove the rule again if the rule was inserted to its new index according 952 // to the list of applied styles. 953 // Note: you might think we would replicate the list-modification logic above, 954 // but that is complicated due to the way the UI installs pseudo-element rules 955 // and the like. 956 if (newIndex !== -1) { 957 this.rules.splice(newIndex, 1); 958 this.rules.splice(oldIndex, 0, newRule); 959 } 960 this._changed(); 961 } catch (e) { 962 console.error(e); 963 } 964 } 965 966 /** 967 * Toggles the enabled state of the given CSS declaration. 968 * 969 * @param {string} ruleId 970 * The Rule id of the given CSS declaration. 971 * @param {string} declarationId 972 * The TextProperty id for the CSS declaration. 973 */ 974 toggleDeclaration(ruleId, declarationId) { 975 const rule = this.getRule(ruleId); 976 if (!rule) { 977 return; 978 } 979 980 const declaration = rule.getDeclaration(declarationId); 981 if (!declaration) { 982 return; 983 } 984 985 declaration.setEnabled(!declaration.enabled); 986 } 987 988 /** 989 * Mark a given TextProperty as overridden or not depending on the 990 * state of its computed properties. Clears the _overriddenDirty state 991 * on all computed properties. 992 * 993 * @param {TextProperty} prop 994 * The text property to update. 995 * @return {boolean} true if the TextProperty's overridden state (or any of 996 * its computed properties overridden state) changed. 997 */ 998 _updatePropertyOverridden(prop) { 999 if (!prop.isValid() && !prop.computed.length) { 1000 prop.overridden = false; 1001 return false; 1002 } 1003 1004 let overridden = true; 1005 let dirty = false; 1006 1007 for (const computedProp of prop.computed) { 1008 if (!computedProp.overridden) { 1009 overridden = false; 1010 } 1011 1012 dirty = computedProp._overriddenDirty || dirty; 1013 delete computedProp._overriddenDirty; 1014 } 1015 1016 dirty = !!prop.overridden !== overridden || dirty; 1017 prop.overridden = overridden; 1018 return dirty; 1019 } 1020 1021 /** 1022 * Returns data about a CSS variable. 1023 * 1024 * @param {string} name 1025 * The name of the variable. 1026 * @param {string} pseudo 1027 * The pseudo-element name of the rule. 1028 * @return {object} An object with the following properties: 1029 * - {String|undefined} value: The variable's value. Undefined if variable is not set. 1030 * - {RegisteredPropertyResource|undefined} registeredProperty: The registered 1031 * property data (syntax, initial value, inherits). Undefined if the variable 1032 * is not a registered property. 1033 */ 1034 getVariableData(name, pseudo = "") { 1035 const variables = this.variablesMap.get(pseudo); 1036 const startingStyleVariables = this.startingStyleVariablesMap.get(pseudo); 1037 const registeredPropertiesMap = 1038 this.ruleView.getRegisteredPropertiesForSelectedNodeTarget(); 1039 1040 const data = {}; 1041 if (variables?.has(name)) { 1042 // XXX Check what to do in case the value doesn't match the registered property syntax. 1043 // Will be handled in Bug 1866712 1044 const { declarationValue, computedValue } = variables.get(name); 1045 data.value = declarationValue; 1046 data.computedValue = computedValue; 1047 } 1048 if (startingStyleVariables?.has(name)) { 1049 data.startingStyle = startingStyleVariables.get(name); 1050 } 1051 if (registeredPropertiesMap?.has(name)) { 1052 data.registeredProperty = registeredPropertiesMap.get(name); 1053 } 1054 1055 return data; 1056 } 1057 1058 /** 1059 * Get all custom properties. 1060 * 1061 * @param {string} pseudo 1062 * The pseudo-element name of the rule. 1063 * @returns Map<String, String> A map whose key is the custom property name and value is 1064 * the custom property value (or registered property initial 1065 * value if the property is not defined) 1066 */ 1067 getAllCustomProperties(pseudo = "") { 1068 const customProperties = new Map(); 1069 for (const [ 1070 key, 1071 { computedValue, declarationValue }, 1072 ] of this.variablesMap.get(pseudo)) { 1073 customProperties.set(key, computedValue ?? declarationValue); 1074 } 1075 1076 const startingStyleCustomProperties = 1077 this.startingStyleVariablesMap.get(pseudo); 1078 1079 const registeredPropertiesMap = 1080 this.ruleView.getRegisteredPropertiesForSelectedNodeTarget(); 1081 1082 // If there's no registered properties nor starting style ones, we can return the Map as is 1083 if ( 1084 (!registeredPropertiesMap || registeredPropertiesMap.size === 0) && 1085 (!startingStyleCustomProperties || 1086 startingStyleCustomProperties.size === 0) 1087 ) { 1088 return customProperties; 1089 } 1090 1091 if (startingStyleCustomProperties) { 1092 for (const [name, value] of startingStyleCustomProperties) { 1093 // Only set the starting style property if it's not defined (i.e. not in the "main" 1094 // variable map) 1095 if (!customProperties.has(name)) { 1096 customProperties.set(name, value); 1097 } 1098 } 1099 } 1100 1101 if (registeredPropertiesMap) { 1102 for (const [name, propertyDefinition] of registeredPropertiesMap) { 1103 // Only set the registered property if it's not defined (i.e. not in the variable map) 1104 if (!customProperties.has(name)) { 1105 customProperties.set(name, propertyDefinition.initialValue); 1106 } 1107 } 1108 } 1109 1110 return customProperties; 1111 } 1112 } 1113 1114 module.exports = ElementStyle;