text-property.js (14443B)
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 { generateUUID } = require("resource://devtools/shared/generate-uuid.js"); 8 const { 9 COMPATIBILITY_TOOLTIP_MESSAGE, 10 } = require("resource://devtools/client/inspector/rules/constants.js"); 11 12 loader.lazyRequireGetter( 13 this, 14 "escapeCSSComment", 15 "resource://devtools/shared/css/parsing-utils.js", 16 true 17 ); 18 19 loader.lazyRequireGetter( 20 this, 21 "getCSSVariables", 22 "resource://devtools/client/inspector/rules/utils/utils.js", 23 true 24 ); 25 26 /** 27 * TextProperty is responsible for the following: 28 * Manages a single property from the authoredText attribute of the 29 * relevant declaration. 30 * Maintains a list of computed properties that come from this 31 * property declaration. 32 * Changes to the TextProperty are sent to its related Rule for 33 * application. 34 */ 35 class TextProperty { 36 /** 37 * @param {object} options 38 * @param {Rule} options.rule 39 * The rule this TextProperty came from. 40 * @param {string} options.name 41 * The text property name (such as "background" or "border-top"). 42 * @param {string} options.value 43 * The property's value (not including priority). 44 * @param {string} options.priority 45 * The property's priority (either "important" or an empty string). 46 * @param {boolean} options.enabled 47 * Whether the property is enabled. 48 * @param {boolean} options.invisible 49 * Whether the property is invisible. In an inherited rule, only show 50 * the inherited declarations. The other declarations are considered 51 * invisible and does not show up in the UI. These are needed so that 52 * the index of a property in Rule.textProps is the same as the index 53 * coming from parseDeclarations. 54 */ 55 constructor({ 56 rule, 57 name, 58 value, 59 priority, 60 enabled = true, 61 invisible = false, 62 }) { 63 this.id = name + "_" + generateUUID().toString(); 64 this.rule = rule; 65 this.name = name; 66 this.value = value; 67 this.priority = priority; 68 this.enabled = !!enabled; 69 this.invisible = invisible; 70 this.elementStyle = this.rule.elementStyle; 71 this.cssProperties = this.elementStyle.ruleView.cssProperties; 72 this.panelDoc = this.elementStyle.ruleView.inspector.panelDoc; 73 this.userProperties = this.elementStyle.store.userProperties; 74 // Names of CSS variables used in the value of this declaration. 75 this.usedVariables = new Set(); 76 77 this.updateComputed(); 78 this.updateUsedVariables(); 79 this.updateIsUnusedVariable(); 80 } 81 82 get computedProperties() { 83 return this.computed 84 .filter(computed => computed.name !== this.name) 85 .map(computed => { 86 return { 87 isOverridden: computed.overridden, 88 name: computed.name, 89 priority: computed.priority, 90 value: computed.value, 91 }; 92 }); 93 } 94 95 /** 96 * Returns whether or not the declaration's name is known. 97 * 98 * @return {boolean} true if the declaration name is known, false otherwise. 99 */ 100 get isKnownProperty() { 101 return this.cssProperties.isKnown(this.name); 102 } 103 104 /** 105 * Returns whether or not the declaration is changed by the user. 106 * 107 * @return {boolean} true if the declaration is changed by the user, false 108 * otherwise. 109 */ 110 get isPropertyChanged() { 111 return this.userProperties.contains(this.rule.domRule, this.name); 112 } 113 114 /** 115 * Update the editor associated with this text property, 116 * if any. 117 */ 118 updateEditor() { 119 // When the editor updates, reset the saved 120 // compatibility issues list as any updates 121 // may alter the compatibility status of declarations 122 this.rule.compatibilityIssues = null; 123 if (this.editor) { 124 this.editor.update(); 125 } 126 } 127 128 /** 129 * Update the list of computed properties for this text property. 130 */ 131 updateComputed() { 132 if (!this.name) { 133 return; 134 } 135 136 // This is a bit funky. To get the list of computed properties 137 // for this text property, we'll set the property on a dummy element 138 // and see what the computed style looks like. 139 const dummyElement = this.elementStyle.ruleView.dummyElement; 140 const dummyStyle = dummyElement.style; 141 dummyStyle.cssText = ""; 142 dummyStyle.setProperty(this.name, this.value, this.priority); 143 144 this.computed = []; 145 146 // Manually get all the properties that are set when setting a value on 147 // this.name and check the computed style on dummyElement for each one. 148 // If we just read dummyStyle, it would skip properties when value === "". 149 const subProps = this.cssProperties.getSubproperties(this.name); 150 151 for (const prop of subProps) { 152 this.computed.push({ 153 textProp: this, 154 name: prop, 155 value: dummyStyle.getPropertyValue(prop), 156 priority: dummyStyle.getPropertyPriority(prop), 157 }); 158 } 159 } 160 161 /** 162 * Extract all CSS variable names used in this declaration's value into a Set for 163 * easy querying. Call this method any time the declaration's value changes. 164 */ 165 updateUsedVariables() { 166 this.usedVariables.clear(); 167 168 for (const variable of getCSSVariables(this.value)) { 169 this.usedVariables.add(variable); 170 } 171 } 172 173 /** 174 * Sets this.isUnusedVariable 175 */ 176 updateIsUnusedVariable() { 177 this.isUnusedVariable = 178 this.name.startsWith("--") && 179 // If an editor was created for the declaration, never hide it back 180 !this.editor && 181 // Don't consider user-added variables, custom properties whose name is the same as 182 // user-added variables, to be unused (we do want to display those to avoid confusion 183 // for the user. 184 !this.userProperties.containsName(this.name) && 185 this.elementStyle.usedVariables && 186 !this.elementStyle.usedVariables.has(this.name); 187 } 188 189 /** 190 * Set all the values from another TextProperty instance into 191 * this TextProperty instance. 192 * 193 * @param {TextProperty} prop 194 * The other TextProperty instance. 195 */ 196 set(prop) { 197 let changed = false; 198 for (const item of ["name", "value", "priority", "enabled"]) { 199 if (this[item] !== prop[item]) { 200 this[item] = prop[item]; 201 changed = true; 202 } 203 } 204 205 if (changed) { 206 this.updateUsedVariables(); 207 this.updateEditor(); 208 } 209 } 210 211 setValue(value, priority, force = false) { 212 if (value !== this.value || force) { 213 this.userProperties.setProperty(this.rule.domRule, this.name, value); 214 } 215 return this.rule.setPropertyValue(this, value, priority).then(() => { 216 this.updateUsedVariables(); 217 this.updateEditor(); 218 }); 219 } 220 221 /** 222 * Called when the property's value has been updated externally, and 223 * the property and editor should update to reflect that value. 224 * 225 * @param {string} value 226 * Property value 227 */ 228 updateValue(value) { 229 if (value !== this.value) { 230 this.value = value; 231 this.updateUsedVariables(); 232 this.updateEditor(); 233 } 234 } 235 236 async setName(name) { 237 if (name !== this.name) { 238 this.userProperties.setProperty(this.rule.domRule, name, this.value); 239 } 240 241 await this.rule.setPropertyName(this, name); 242 this.updateEditor(); 243 } 244 245 setEnabled(value) { 246 this.rule.setPropertyEnabled(this, value); 247 this.updateEditor(); 248 } 249 250 remove() { 251 this.rule.removeProperty(this); 252 } 253 254 /** 255 * Return a string representation of the rule property. 256 */ 257 stringifyProperty() { 258 // Get the displayed property value 259 let declaration = this.name + ": " + this.value; 260 261 if (this.priority) { 262 declaration += " !" + this.priority; 263 } 264 265 declaration += ";"; 266 267 // Comment out property declarations that are not enabled 268 if (!this.enabled) { 269 declaration = "/* " + escapeCSSComment(declaration) + " */"; 270 } 271 272 return declaration; 273 } 274 275 /** 276 * Returns the associated StyleRule declaration if it exists 277 * 278 * @returns {object | undefined} 279 */ 280 #getDomRuleDeclaration() { 281 const selfIndex = this.rule.textProps.indexOf(this); 282 return this.rule.domRule.declarations?.[selfIndex]; 283 } 284 285 /** 286 * Validate this property. Does it make sense for this value to be assigned 287 * to this property name? 288 * 289 * @return {boolean} true if the whole CSS declaration is valid, false otherwise. 290 */ 291 isValid() { 292 const declaration = this.#getDomRuleDeclaration(); 293 294 // When adding a new property in the rule-view, the TextProperty object is 295 // created right away before the rule gets updated on the server, so we're 296 // not going to find the corresponding declaration object yet. Default to 297 // true. 298 if (!declaration) { 299 return true; 300 } 301 302 return declaration.isValid; 303 } 304 305 /** 306 * Returns an object with properties explaining why the property is inactive, if it is. 307 * If it's not inactive, this returns undefined. 308 * 309 * @returns {object | undefined} 310 */ 311 getInactiveCssData() { 312 const declaration = this.#getDomRuleDeclaration(); 313 314 if (!declaration) { 315 return undefined; 316 } 317 318 return declaration.inactiveCssData; 319 } 320 321 /** 322 * Get compatibility issue linked with the textProp. 323 * 324 * @returns A JSON objects with compatibility information in following form: 325 * { 326 * // A boolean to denote the compatibility status 327 * isCompatible: <boolean>, 328 * // The CSS declaration that has compatibility issues 329 * property: <string>, 330 * // The un-aliased root CSS declaration for the given property 331 * rootProperty: <string>, 332 * // The l10n message id for the tooltip message 333 * msgId: <string>, 334 * // Link to MDN documentation for the rootProperty 335 * url: <string>, 336 * // An array of all the browsers that don't support the given CSS rule 337 * unsupportedBrowsers: <Array>, 338 * } 339 */ 340 async isCompatible() { 341 // This is a workaround for Bug 1648339 342 // https://bugzilla.mozilla.org/show_bug.cgi?id=1648339 343 // that makes the tooltip icon inconsistent with the 344 // position of the rule it is associated with. Once solved, 345 // the compatibility data can be directly accessed from the 346 // declaration and this logic can be used to set isCompatible 347 // property directly to domRule in StyleRuleActor's form() method. 348 if (!this.enabled) { 349 return { isCompatible: true }; 350 } 351 352 const compatibilityIssues = await this.rule.getCompatibilityIssues(); 353 if (!compatibilityIssues.length) { 354 return { isCompatible: true }; 355 } 356 357 const property = this.name; 358 const indexOfProperty = compatibilityIssues.findIndex( 359 issue => issue.property === property || issue.aliases?.includes(property) 360 ); 361 362 if (indexOfProperty < 0) { 363 return { isCompatible: true }; 364 } 365 366 const { 367 property: rootProperty, 368 deprecated, 369 experimental, 370 specUrl, 371 url, 372 unsupportedBrowsers, 373 } = compatibilityIssues[indexOfProperty]; 374 375 let msgId = COMPATIBILITY_TOOLTIP_MESSAGE.default; 376 if (deprecated && experimental && !unsupportedBrowsers.length) { 377 msgId = 378 COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-experimental-supported"]; 379 } else if (deprecated && experimental) { 380 msgId = COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-experimental"]; 381 } else if (deprecated && !unsupportedBrowsers.length) { 382 msgId = COMPATIBILITY_TOOLTIP_MESSAGE["deprecated-supported"]; 383 } else if (deprecated) { 384 msgId = COMPATIBILITY_TOOLTIP_MESSAGE.deprecated; 385 } else if (experimental && !unsupportedBrowsers.length) { 386 msgId = COMPATIBILITY_TOOLTIP_MESSAGE["experimental-supported"]; 387 } else if (experimental) { 388 msgId = COMPATIBILITY_TOOLTIP_MESSAGE.experimental; 389 } 390 391 return { 392 isCompatible: false, 393 property, 394 rootProperty, 395 msgId, 396 specUrl, 397 url, 398 unsupportedBrowsers, 399 }; 400 } 401 402 /** 403 * Validate the name of this property. 404 * 405 * @return {boolean} true if the property name is valid, false otherwise. 406 */ 407 isNameValid() { 408 const declaration = this.#getDomRuleDeclaration(); 409 410 // When adding a new property in the rule-view, the TextProperty object is 411 // created right away before the rule gets updated on the server, so we're 412 // not going to find the corresponding declaration object yet. Default to 413 // true. 414 if (!declaration) { 415 return true; 416 } 417 418 return declaration.isNameValid; 419 } 420 421 /** 422 * Returns whether the property is invalid at computed-value time. 423 * For now, it's only computed on the server for declarations of 424 * registered properties. 425 * 426 * @return {boolean} 427 */ 428 isInvalidAtComputedValueTime() { 429 const declaration = this.#getDomRuleDeclaration(); 430 // When adding a new property in the rule-view, the TextProperty object is 431 // created right away before the rule gets updated on the server, so we're 432 // not going to find the corresponding declaration object yet. Default to 433 // false. 434 if (!declaration) { 435 return false; 436 } 437 438 return declaration.invalidAtComputedValueTime; 439 } 440 441 /** 442 * Get the associated CSS variable computed value. 443 * 444 * @return {string} 445 */ 446 getVariableComputedValue() { 447 const declaration = this.#getDomRuleDeclaration(); 448 // When adding a new property in the rule-view, the TextProperty object is 449 // created right away before the rule gets updated on the server, so we're 450 // not going to find the corresponding declaration object yet. Default to null. 451 if (!declaration || !declaration.isCustomProperty) { 452 return null; 453 } 454 455 return declaration.computedValue; 456 } 457 458 /** 459 * Returns the expected syntax for this property. 460 * For now, it's only sent from the server for invalid at computed-value time declarations. 461 * 462 * @return {string | null} The expected syntax, or null. 463 */ 464 getExpectedSyntax() { 465 const declaration = this.#getDomRuleDeclaration(); 466 // When adding a new property in the rule-view, the TextProperty object is 467 // created right away before the rule gets updated on the server, so we're 468 // not going to find the corresponding declaration object yet. Default to 469 // null. 470 if (!declaration) { 471 return null; 472 } 473 474 return declaration.syntax; 475 } 476 } 477 478 module.exports = TextProperty;