text-property-editor.js (62337B)
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 { 8 l10n, 9 l10nFormatStr, 10 } = require("resource://devtools/shared/inspector/css-logic.js"); 11 const { 12 InplaceEditor, 13 editableField, 14 } = require("resource://devtools/client/shared/inplace-editor.js"); 15 const { 16 createChild, 17 appendText, 18 advanceValidate, 19 blurOnMultipleProperties, 20 } = require("resource://devtools/client/inspector/shared/utils.js"); 21 const { throttle } = require("resource://devtools/shared/throttle.js"); 22 const { 23 style: { ELEMENT_STYLE }, 24 } = require("resource://devtools/shared/constants.js"); 25 const { 26 canPointerEventDrag, 27 } = require("resource://devtools/client/shared/events.js"); 28 29 loader.lazyRequireGetter( 30 this, 31 ["parseDeclarations", "parseSingleValue"], 32 "resource://devtools/shared/css/parsing-utils.js", 33 true 34 ); 35 loader.lazyRequireGetter( 36 this, 37 "findCssSelector", 38 "resource://devtools/shared/inspector/css-logic.js", 39 true 40 ); 41 loader.lazyGetter(this, "PROPERTY_NAME_INPUT_LABEL", function () { 42 return l10n("rule.propertyName.label"); 43 }); 44 loader.lazyGetter(this, "SHORTHAND_EXPANDER_TOOLTIP", function () { 45 return l10n("rule.shorthandExpander.tooltip"); 46 }); 47 48 const lazy = {}; 49 ChromeUtils.defineESModuleGetters(lazy, { 50 AppConstants: "resource://gre/modules/AppConstants.sys.mjs", 51 }); 52 53 const HTML_NS = "http://www.w3.org/1999/xhtml"; 54 55 const SHARED_SWATCH_CLASS = "inspector-swatch"; 56 const COLOR_SWATCH_CLASS = "inspector-colorswatch"; 57 const BEZIER_SWATCH_CLASS = "inspector-bezierswatch"; 58 const LINEAR_EASING_SWATCH_CLASS = "inspector-lineareasingswatch"; 59 const FILTER_SWATCH_CLASS = "inspector-filterswatch"; 60 const ANGLE_SWATCH_CLASS = "inspector-angleswatch"; 61 const FONT_FAMILY_CLASS = "ruleview-font-family"; 62 const SHAPE_SWATCH_CLASS = "inspector-shapeswatch"; 63 64 /* 65 * An actionable element is an element which on click triggers a specific action 66 * (e.g. shows a color tooltip, opens a link, …). 67 */ 68 const ACTIONABLE_ELEMENTS_SELECTORS = [ 69 `.${COLOR_SWATCH_CLASS}`, 70 `.${BEZIER_SWATCH_CLASS}`, 71 `.${LINEAR_EASING_SWATCH_CLASS}`, 72 `.${FILTER_SWATCH_CLASS}`, 73 `.${ANGLE_SWATCH_CLASS}`, 74 "a", 75 ]; 76 77 /* 78 * Speeds at which we update the value when the user is dragging its mouse 79 * over a value. 80 */ 81 const SLOW_DRAGGING_SPEED = 0.1; 82 const DEFAULT_DRAGGING_SPEED = 1; 83 const FAST_DRAGGING_SPEED = 10; 84 85 // Deadzone in pixels where dragging should not update the value. 86 const DRAGGING_DEADZONE_DISTANCE = 5; 87 88 const DRAGGABLE_VALUE_CLASSNAME = "ruleview-propertyvalue-draggable"; 89 const IS_DRAGGING_CLASSNAME = "ruleview-propertyvalue-dragging"; 90 91 /** 92 * TextPropertyEditor is responsible for the following: 93 * Owns a TextProperty object. 94 * Manages changes to the TextProperty. 95 * Can be expanded to display computed properties. 96 * Can mark a property disabled or enabled. 97 * 98 * @param {RuleEditor} ruleEditor 99 * The rule editor that owns this TextPropertyEditor. 100 * @param {TextProperty} property 101 * The text property to edit. 102 * @param {object} options 103 * @param {Set} options.elementsWithPendingClicks 104 */ 105 class TextPropertyEditor { 106 constructor(ruleEditor, property, options) { 107 this.ruleEditor = ruleEditor; 108 this.ruleView = this.ruleEditor.ruleView; 109 this.cssProperties = this.ruleView.cssProperties; 110 this.doc = this.ruleEditor.doc; 111 this.popup = this.ruleView.popup; 112 this.prop = property; 113 this.prop.editor = this; 114 this.browserWindow = this.doc.defaultView.top; 115 this.#elementsWithPendingClicks = options.elementsWithPendingClicks; 116 117 this.toolbox = this.ruleView.inspector.toolbox; 118 this.telemetry = this.toolbox.telemetry; 119 120 this.#onValidate = this.ruleView.debounce(this.#previewValue, 10, this); 121 122 this.#createUI(); 123 this.update(); 124 } 125 126 #populatedComputed = false; 127 #hasPendingClick = false; 128 #clickedElementOptions = null; 129 #populatedShorthandOverridden; 130 #elementsWithPendingClicks; 131 132 #colorSwatchSpans; 133 #bezierSwatchSpans; 134 #linearEasingSwatchSpans; 135 136 #onValidate; 137 #isDragging = false; 138 #capturingPointerId = null; 139 #hasDragged = false; 140 #draggingController = null; 141 #draggingValueCache = null; 142 143 /** 144 * Boolean indicating if the name or value is being currently edited. 145 */ 146 get editing() { 147 return ( 148 !!( 149 this.nameSpan.inplaceEditor || 150 this.valueSpan.inplaceEditor || 151 this.ruleView.tooltips.isEditing 152 ) || this.popup.isOpen 153 ); 154 } 155 156 /** 157 * Get the rule to the current text property 158 */ 159 get rule() { 160 return this.prop.rule; 161 } 162 163 // Exposed for tests. 164 get _DRAGGING_DEADZONE_DISTANCE() { 165 return DRAGGING_DEADZONE_DISTANCE; 166 } 167 168 /** 169 * Create the property editor's DOM. 170 */ 171 #createUI() { 172 const win = this.doc.defaultView; 173 this.abortController = new win.AbortController(); 174 175 this.element = this.doc.createElementNS(HTML_NS, "div"); 176 this.element.setAttribute("role", "listitem"); 177 this.element.classList.add("ruleview-property"); 178 this.element.dataset.declarationId = this.prop.id; 179 this.element._textPropertyEditor = this; 180 181 this.container = createChild(this.element, "div", { 182 class: "ruleview-propertycontainer", 183 }); 184 185 const indent = 186 ((this.ruleEditor.rule.domRule.ancestorData.length || 0) + 1) * 2; 187 createChild(this.container, "span", { 188 class: "ruleview-rule-indent clipboard-only", 189 textContent: " ".repeat(indent), 190 }); 191 192 // The enable checkbox will disable or enable the rule. 193 this.enable = createChild(this.container, "input", { 194 type: "checkbox", 195 class: "ruleview-enableproperty", 196 title: l10nFormatStr("rule.propertyToggle.label", this.prop.name), 197 }); 198 199 this.nameContainer = createChild(this.container, "span", { 200 class: "ruleview-namecontainer", 201 }); 202 203 // Property name, editable when focused. Property name 204 // is committed when the editor is unfocused. 205 this.nameSpan = createChild(this.nameContainer, "span", { 206 class: "ruleview-propertyname theme-fg-color3", 207 tabindex: this.ruleEditor.isEditable ? "0" : "-1", 208 id: this.prop.id, 209 }); 210 211 appendText(this.nameContainer, ": "); 212 213 // Create a span that will hold the property and semicolon. 214 // Use this span to create a slightly larger click target 215 // for the value. 216 this.valueContainer = createChild(this.container, "span", { 217 class: "ruleview-propertyvaluecontainer", 218 }); 219 220 // Property value, editable when focused. Changes to the 221 // property value are applied as they are typed, and reverted 222 // if the user presses escape. 223 this.valueSpan = createChild(this.valueContainer, "span", { 224 class: "ruleview-propertyvalue theme-fg-color1", 225 tabindex: this.ruleEditor.isEditable ? "0" : "-1", 226 }); 227 228 // Storing the TextProperty on the elements for easy access 229 // (for instance by the tooltip) 230 this.valueSpan.textProperty = this.prop; 231 this.nameSpan.textProperty = this.prop; 232 233 appendText(this.valueContainer, ";"); 234 235 // This needs to be called after valueContainer, nameSpan and valueSpan are created. 236 if (this.#shouldShowComputedExpander) { 237 this.#createComputedExpander(); 238 } 239 240 if (this.#shouldShowWarning) { 241 this.#createWarningIcon(); 242 } 243 244 if (this.#isInvalidAtComputedValueTime()) { 245 this.#createInvalidAtComputedValueTimeIcon(); 246 } 247 248 if (this.#shouldShowInactiveCssState) { 249 this.#createInactiveCssWarningIcon(); 250 } 251 252 if (this.#shouldShowFilterProperty) { 253 this.#createFilterPropertyButton(); 254 } 255 256 // Only bind event handlers if the rule is editable. 257 if (this.ruleEditor.isEditable) { 258 this.enable.addEventListener("click", this.#onEnableClicked, { 259 signal: this.abortController.signal, 260 capture: true, 261 }); 262 this.enable.addEventListener("change", this.#onEnableChanged, { 263 signal: this.abortController.signal, 264 capture: true, 265 }); 266 267 this.nameContainer.addEventListener( 268 "click", 269 event => { 270 // Clicks within the name shouldn't propagate any further. 271 event.stopPropagation(); 272 273 // Forward clicks on nameContainer to the editable nameSpan 274 if (event.target === this.nameContainer) { 275 this.nameSpan.click(); 276 } 277 }, 278 { signal: this.abortController.signal } 279 ); 280 281 const getCssVariables = () => 282 this.rule.elementStyle.getAllCustomProperties(this.rule.pseudoElement); 283 284 editableField({ 285 start: this.#onStartEditing, 286 element: this.nameSpan, 287 done: this.#onNameDone, 288 destroy: this.updateUI, 289 advanceChars: ":", 290 contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY, 291 popup: this.popup, 292 cssProperties: this.cssProperties, 293 getCssVariables, 294 // (Shift+)Tab will move the focus to the previous/next editable field (so property value 295 // or new selector). 296 focusEditableFieldAfterApply: true, 297 focusEditableFieldContainerSelector: ".ruleview-rule", 298 // We don't want Enter to trigger the next editable field, just to validate 299 // what the user entered, close the editor, and focus the span so the user can 300 // navigate with the keyboard as expected, unless the user has 301 // devtools.inspector.rule-view.focusNextOnEnter set to true 302 stopOnReturn: this.ruleView.inplaceEditorFocusNextOnEnter !== true, 303 inputAriaLabel: PROPERTY_NAME_INPUT_LABEL, 304 }); 305 306 // Auto blur name field on multiple CSS rules get pasted in. 307 this.nameContainer.addEventListener( 308 "paste", 309 blurOnMultipleProperties(this.cssProperties), 310 { signal: this.abortController.signal } 311 ); 312 313 this.valueContainer.addEventListener( 314 "click", 315 event => { 316 // Clicks within the value shouldn't propagate any further. 317 event.stopPropagation(); 318 319 // Forward clicks on valueContainer to the editable valueSpan 320 if (event.target === this.valueContainer) { 321 this.valueSpan.click(); 322 } 323 324 if (event.target.classList.contains("ruleview-variable-link")) { 325 const isRuleInStartingStyle = 326 this.ruleEditor.rule.isInStartingStyle(); 327 const rulePseudoElement = this.ruleEditor.rule.pseudoElement; 328 this.ruleView.highlightProperty(event.target.dataset.variableName, { 329 ruleValidator: rule => { 330 // If the associated rule is not in starting style, the variable 331 // definition can't be in a starting style rule. 332 // Note that if the rule is in starting style, then the variable 333 // definition might be in a starting style rule, or in a regular one. 334 if (!isRuleInStartingStyle && rule.isInStartingStyle()) { 335 return false; 336 } 337 338 if ( 339 rule.pseudoElement && 340 rulePseudoElement !== rule.pseudoElement 341 ) { 342 return false; 343 } 344 345 return true; 346 }, 347 }); 348 } 349 }, 350 { signal: this.abortController.signal } 351 ); 352 353 // The mousedown event could trigger a blur event on nameContainer, which 354 // will trigger a call to the update function. The update function clears 355 // valueSpan's markup. Thus the regular click event does not bubble up, and 356 // listener's callbacks are not called. 357 // So we need to remember where the user clicks in order to re-trigger the click 358 // after the valueSpan's markup is re-populated. We only need to track this for 359 // valueSpan's child elements, because direct click on valueSpan will always 360 // trigger a click event. 361 this.valueSpan.addEventListener( 362 "mousedown", 363 event => { 364 const clickedEl = event.target; 365 if (clickedEl === this.valueSpan) { 366 return; 367 } 368 this.#hasPendingClick = true; 369 this.#elementsWithPendingClicks.add(this.valueSpan); 370 371 const matchedSelector = ACTIONABLE_ELEMENTS_SELECTORS.find(selector => 372 clickedEl.matches(selector) 373 ); 374 if (matchedSelector) { 375 const similarElements = [ 376 ...this.valueSpan.querySelectorAll(matchedSelector), 377 ]; 378 this.#clickedElementOptions = { 379 selector: matchedSelector, 380 index: similarElements.indexOf(clickedEl), 381 }; 382 } 383 }, 384 { signal: this.abortController.signal } 385 ); 386 387 this.valueSpan.addEventListener( 388 "pointerup", 389 () => { 390 // if we have dragged, we will handle the pending click in #draggingOnPointerUp instead 391 if (this.#hasDragged) { 392 return; 393 } 394 this.#clickedElementOptions = null; 395 this.#hasPendingClick = false; 396 this.#elementsWithPendingClicks.delete(this.valueSpan); 397 }, 398 { signal: this.abortController.signal } 399 ); 400 401 this.ruleView.on( 402 "draggable-preference-updated", 403 this.#onDraggablePreferenceChanged, 404 { signal: this.abortController.signal } 405 ); 406 if (this.#isDraggableProperty(this.prop)) { 407 this.#addDraggingCapability(); 408 } 409 410 editableField({ 411 start: this.#onStartEditing, 412 element: this.valueSpan, 413 done: this.#onValueDone, 414 destroy: onValueDonePromise => { 415 const cb = this.update; 416 // The `done` callback is called before this `destroy` callback is. 417 // In #onValueDone, we might preview/set the property and we want to wait for 418 // that to be resolved before updating the view so all data are up to date (see Bug 1325145). 419 if ( 420 onValueDonePromise && 421 typeof onValueDonePromise.then === "function" 422 ) { 423 return onValueDonePromise.then(cb); 424 } 425 return cb(); 426 }, 427 validate: this.#onValidate, 428 advanceChars: advanceValidate, 429 contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE, 430 property: this.prop, 431 defaultIncrement: this.prop.name === "opacity" ? 0.1 : 1, 432 popup: this.popup, 433 multiline: true, 434 maxWidth: () => this.container.getBoundingClientRect().width, 435 cssProperties: this.cssProperties, 436 getCssVariables, 437 getGridLineNames: this.#getGridlineNames, 438 showSuggestCompletionOnEmpty: true, 439 // (Shift+)Tab will move the focus to the previous/next editable field (so property name, 440 // or new property). 441 focusEditableFieldAfterApply: true, 442 focusEditableFieldContainerSelector: ".ruleview-rule", 443 // We don't want Enter to trigger the next editable field, just to validate 444 // what the user entered, close the editor, and focus the span so the user can 445 // navigate with the keyboard as expected, unless the user has 446 // devtools.inspector.rule-view.focusNextOnEnter set to true 447 stopOnReturn: this.ruleView.inplaceEditorFocusNextOnEnter !== true, 448 // Label the value input with the name span so screenreader users know what this 449 // applies to. 450 inputAriaLabelledBy: this.nameSpan.id, 451 }); 452 } 453 } 454 455 /** 456 * Get the grid line names of the grid that the currently selected element is 457 * contained in. 458 * 459 * @return {object} Contains the names of the cols and rows as arrays 460 * {cols: [], rows: []}. 461 */ 462 #getGridlineNames = async () => { 463 const gridLineNames = { cols: [], rows: [] }; 464 const layoutInspector = 465 await this.ruleView.inspector.walker.getLayoutInspector(); 466 const gridFront = await layoutInspector.getCurrentGrid( 467 this.ruleView.inspector.selection.nodeFront 468 ); 469 470 if (gridFront) { 471 const gridFragments = gridFront.gridFragments; 472 473 for (const gridFragment of gridFragments) { 474 for (const rowLine of gridFragment.rows.lines) { 475 // We specifically ignore implicit line names created from implicitly named 476 // areas. This is because showing implicit line names can be confusing for 477 // designers who may have used a line name with "-start" or "-end" and created 478 // an implicitly named grid area without meaning to. 479 let gridArea; 480 481 for (const name of rowLine.names) { 482 const rowLineName = 483 name.substring(0, name.lastIndexOf("-start")) || 484 name.substring(0, name.lastIndexOf("-end")); 485 gridArea = gridFragment.areas.find( 486 area => area.name === rowLineName 487 ); 488 489 if ( 490 rowLine.type === "implicit" && 491 gridArea && 492 gridArea.type === "implicit" 493 ) { 494 continue; 495 } 496 gridLineNames.rows.push(name); 497 } 498 } 499 500 for (const colLine of gridFragment.cols.lines) { 501 let gridArea; 502 503 for (const name of colLine.names) { 504 const colLineName = 505 name.substring(0, name.lastIndexOf("-start")) || 506 name.substring(0, name.lastIndexOf("-end")); 507 gridArea = gridFragment.areas.find( 508 area => area.name === colLineName 509 ); 510 511 if ( 512 colLine.type === "implicit" && 513 gridArea && 514 gridArea.type === "implicit" 515 ) { 516 continue; 517 } 518 gridLineNames.cols.push(name); 519 } 520 } 521 } 522 } 523 524 // Emit message for test files 525 this.ruleView.inspector.emit("grid-line-names-updated"); 526 return gridLineNames; 527 }; 528 529 /** 530 * Get the path from which to resolve requests for this 531 * rule's stylesheet. 532 * 533 * @return {string} the stylesheet's href. 534 */ 535 get #sheetHref() { 536 const domRule = this.rule.domRule; 537 if (domRule) { 538 return domRule.href || domRule.nodeHref; 539 } 540 return undefined; 541 } 542 543 /** 544 * Populate the span based on changes to the TextProperty. 545 */ 546 // eslint-disable-next-line complexity 547 update = () => { 548 if (this.ruleView.isDestroyed) { 549 return; 550 } 551 552 this.updateUI(); 553 554 const name = this.prop.name; 555 this.nameSpan.textContent = name; 556 this.enable.setAttribute( 557 "title", 558 l10nFormatStr("rule.propertyToggle.label", name) 559 ); 560 561 // Combine the property's value and priority into one string for 562 // the value. 563 const store = this.rule.elementStyle.store; 564 let val = store.userProperties.getProperty( 565 this.rule.domRule, 566 name, 567 this.prop.value 568 ); 569 if (this.prop.priority) { 570 val += " !" + this.prop.priority; 571 } 572 573 const propDirty = this.prop.isPropertyChanged; 574 575 if (propDirty) { 576 this.element.setAttribute("dirty", ""); 577 } else { 578 this.element.removeAttribute("dirty"); 579 } 580 581 const outputParser = this.ruleView._outputParser; 582 this.outputParserOptions = { 583 angleClass: "ruleview-angle", 584 angleSwatchClass: SHARED_SWATCH_CLASS + " " + ANGLE_SWATCH_CLASS, 585 bezierClass: "ruleview-bezier", 586 bezierSwatchClass: SHARED_SWATCH_CLASS + " " + BEZIER_SWATCH_CLASS, 587 colorClass: "ruleview-color", 588 colorSwatchClass: SHARED_SWATCH_CLASS + " " + COLOR_SWATCH_CLASS, 589 filterClass: "ruleview-filter", 590 filterSwatchClass: SHARED_SWATCH_CLASS + " " + FILTER_SWATCH_CLASS, 591 flexClass: "inspector-flex js-toggle-flexbox-highlighter", 592 gridClass: "inspector-grid js-toggle-grid-highlighter", 593 linearEasingClass: "ruleview-lineareasing", 594 linearEasingSwatchClass: 595 SHARED_SWATCH_CLASS + " " + LINEAR_EASING_SWATCH_CLASS, 596 shapeClass: "inspector-shape", 597 shapeSwatchClass: SHAPE_SWATCH_CLASS, 598 // Only ask the parser to convert colors to the default color type specified by the 599 // user if the property hasn't been changed yet. 600 useDefaultColorUnit: !propDirty, 601 defaultColorUnit: this.ruleView.inspector.defaultColorUnit, 602 urlClass: "theme-link", 603 fontFamilyClass: FONT_FAMILY_CLASS, 604 baseURI: this.#sheetHref, 605 unmatchedClass: "inspector-unmatched", 606 matchedVariableClass: "inspector-variable", 607 getVariableData: varName => 608 this.rule.elementStyle.getVariableData( 609 varName, 610 this.rule.pseudoElement 611 ), 612 inStartingStyleRule: this.rule.isInStartingStyle(), 613 isValid: this.isValid(), 614 }; 615 616 if (this.rule.darkColorScheme !== undefined) { 617 this.outputParserOptions.isDarkColorScheme = this.rule.darkColorScheme; 618 } 619 const frag = outputParser.parseCssProperty( 620 name, 621 val, 622 this.outputParserOptions 623 ); 624 625 // Save the initial value as the last committed value, 626 // for restoring after pressing escape. 627 if (!this.committed) { 628 this.committed = { 629 name, 630 value: frag.textContent, 631 priority: this.prop.priority, 632 }; 633 } 634 635 // Save focused element inside value span if one exists before wiping the innerHTML 636 let focusedElSelector = null; 637 if (this.valueSpan.contains(this.doc.activeElement)) { 638 focusedElSelector = findCssSelector(this.doc.activeElement); 639 } 640 641 this.valueSpan.innerHTML = ""; 642 this.valueSpan.appendChild(frag); 643 if ( 644 this.valueSpan.textProperty?.name === "grid-template-areas" && 645 (this.valueSpan.innerText.includes(`"`) || 646 this.valueSpan.innerText.includes(`'`)) 647 ) { 648 this.#formatGridTemplateAreasValue(); 649 } 650 651 this.ruleView.emit("property-value-updated", { 652 rule: this.prop.rule, 653 property: name, 654 value: val, 655 }); 656 657 // Highlight the currently used font in font-family properties. 658 // If we cannot find a match, highlight the first generic family instead. 659 const fontFamilySpans = this.valueSpan.querySelectorAll( 660 "." + FONT_FAMILY_CLASS 661 ); 662 if (fontFamilySpans.length && this.prop.enabled && !this.prop.overridden) { 663 this.rule.elementStyle 664 .getUsedFontFamilies() 665 .then(families => { 666 for (const span of fontFamilySpans) { 667 const authoredFont = span.textContent.toLowerCase(); 668 if (families.has(authoredFont)) { 669 span.classList.add("used-font"); 670 // In case a font-family appears multiple time in the value, we only want 671 // to highlight the first occurence. 672 families.delete(authoredFont); 673 } 674 } 675 676 this.ruleView.emit("font-highlighted", this.valueSpan); 677 }) 678 .catch(e => 679 console.error("Could not get the list of font families", e) 680 ); 681 } 682 683 // Attach the color picker tooltip to the color swatches 684 this.#colorSwatchSpans = this.valueSpan.querySelectorAll( 685 "." + COLOR_SWATCH_CLASS 686 ); 687 if (this.ruleEditor.isEditable) { 688 for (const span of this.#colorSwatchSpans) { 689 // Adding this swatch to the list of swatches our colorpicker 690 // knows about 691 this.ruleView.tooltips.getTooltip("colorPicker").addSwatch(span, { 692 onShow: this.#onStartEditing, 693 onPreview: this.#onSwatchPreview, 694 onCommit: this.#onSwatchCommit, 695 onRevert: this.#onSwatchRevert, 696 }); 697 const title = l10n("rule.colorSwatch.tooltip"); 698 span.setAttribute("title", title); 699 span.dataset.propertyName = this.nameSpan.textContent; 700 } 701 } 702 703 // Attach the cubic-bezier tooltip to the bezier swatches 704 this.#bezierSwatchSpans = this.valueSpan.querySelectorAll( 705 "." + BEZIER_SWATCH_CLASS 706 ); 707 if (this.ruleEditor.isEditable) { 708 for (const span of this.#bezierSwatchSpans) { 709 // Adding this swatch to the list of swatches our colorpicker 710 // knows about 711 this.ruleView.tooltips.getTooltip("cubicBezier").addSwatch(span, { 712 onShow: this.#onStartEditing, 713 onPreview: this.#onSwatchPreview, 714 onCommit: this.#onSwatchCommit, 715 onRevert: this.#onSwatchRevert, 716 }); 717 const title = l10n("rule.bezierSwatch.tooltip"); 718 span.setAttribute("title", title); 719 } 720 } 721 722 // Attach the linear easing tooltip to the linear easing swatches 723 this.#linearEasingSwatchSpans = this.valueSpan.querySelectorAll( 724 "." + LINEAR_EASING_SWATCH_CLASS 725 ); 726 if (this.ruleEditor.isEditable) { 727 for (const span of this.#linearEasingSwatchSpans) { 728 // Adding this swatch to the list of swatches our colorpicker 729 // knows about 730 this.ruleView.tooltips 731 .getTooltip("linearEaseFunction") 732 .addSwatch(span, { 733 onShow: this.#onStartEditing, 734 onPreview: this.#onSwatchPreview, 735 onCommit: this.#onSwatchCommit, 736 onRevert: this.#onSwatchRevert, 737 }); 738 span.setAttribute("title", l10n("rule.bezierSwatch.tooltip")); 739 } 740 } 741 742 // Attach the filter editor tooltip to the filter swatch 743 const span = this.valueSpan.querySelector("." + FILTER_SWATCH_CLASS); 744 if (this.ruleEditor.isEditable) { 745 if (span) { 746 this.outputParserOptions.filterSwatch = true; 747 748 this.ruleView.tooltips.getTooltip("filterEditor").addSwatch( 749 span, 750 { 751 onShow: this.#onStartEditing, 752 onPreview: this.#onSwatchPreview, 753 onCommit: this.#onSwatchCommit, 754 onRevert: this.#onSwatchRevert, 755 }, 756 outputParser, 757 this.outputParserOptions 758 ); 759 const title = l10n("rule.filterSwatch.tooltip"); 760 span.setAttribute("title", title); 761 } 762 } 763 764 this.angleSwatchSpans = this.valueSpan.querySelectorAll( 765 "." + ANGLE_SWATCH_CLASS 766 ); 767 if (this.ruleEditor.isEditable) { 768 for (const angleSpan of this.angleSwatchSpans) { 769 angleSpan.addEventListener("unit-change", this.#onSwatchCommit); 770 const title = l10n("rule.angleSwatch.tooltip"); 771 angleSpan.setAttribute("title", title); 772 } 773 } 774 775 const nodeFront = this.ruleView.inspector.selection.nodeFront; 776 777 const flexToggle = this.valueSpan.querySelector(".inspector-flex"); 778 if (flexToggle) { 779 flexToggle.setAttribute("title", l10n("rule.flexToggle.tooltip")); 780 flexToggle.setAttribute( 781 "aria-pressed", 782 this.ruleView.inspector.highlighters.getNodeForActiveHighlighter( 783 this.ruleView.inspector.highlighters.TYPES.FLEXBOX 784 ) === nodeFront 785 ); 786 } 787 788 const gridToggle = this.valueSpan.querySelector(".inspector-grid"); 789 if (gridToggle) { 790 gridToggle.setAttribute("title", l10n("rule.gridToggle.tooltip")); 791 gridToggle.setAttribute( 792 "aria-pressed", 793 this.ruleView.highlighters.gridHighlighters.has(nodeFront) 794 ); 795 gridToggle.toggleAttribute( 796 "disabled", 797 !this.ruleView.highlighters.canGridHighlighterToggle(nodeFront) 798 ); 799 } 800 801 const shapeToggle = this.valueSpan.querySelector(".inspector-shapeswatch"); 802 if (shapeToggle) { 803 const mode = 804 "css" + 805 name 806 .split("-") 807 .map(s => { 808 return s[0].toUpperCase() + s.slice(1); 809 }) 810 .join(""); 811 shapeToggle.setAttribute("data-mode", mode); 812 shapeToggle.setAttribute("aria-pressed", false); 813 shapeToggle.setAttribute("title", l10n("rule.shapeToggle.tooltip")); 814 } 815 816 // Now that we have updated the property's value, we might have a pending 817 // click on the value container. If we do, we have to trigger a click event 818 // on the right element. 819 // If we are dragging, we don't need to handle the pending click 820 if (this.#hasPendingClick && !this.#isDragging) { 821 this.#hasPendingClick = false; 822 this.#elementsWithPendingClicks.delete(this.valueSpan); 823 let elToClick; 824 825 if (this.#clickedElementOptions !== null) { 826 const { selector, index } = this.#clickedElementOptions; 827 elToClick = this.valueSpan.querySelectorAll(selector)[index]; 828 829 this.#clickedElementOptions = null; 830 } 831 832 if (!elToClick) { 833 elToClick = this.valueSpan; 834 } 835 elToClick.click(); 836 } 837 838 // Populate the computed styles and shorthand overridden styles. 839 this.#updateComputed(); 840 this.#updateShorthandOverridden(); 841 842 // Update the rule property highlight. 843 this.ruleView._updatePropertyHighlight(this); 844 845 // Restore focus back to the element whose markup was recreated above, if 846 // the focus is still in the current document (avoid stealing the focus, see 847 // Bug 1911627). 848 if (this.doc.hasFocus() && focusedElSelector) { 849 const elementToFocus = this.doc.querySelector(focusedElSelector); 850 if (elementToFocus) { 851 elementToFocus.focus(); 852 } 853 } 854 }; 855 856 #onStartEditing = () => { 857 this.element.classList.remove("ruleview-overridden", "ruleview-invalid"); 858 this.enable.style.visibility = "hidden"; 859 if (this.filterProperty) { 860 this.filterProperty.hidden = true; 861 } 862 if (this.expander) { 863 this.expander.hidden = true; 864 } 865 }; 866 867 get #shouldShowComputedExpander() { 868 if (this.prop.name.startsWith("--") || this.editing) { 869 return false; 870 } 871 872 // Only show the expander to reveal computed properties if: 873 // - the computed properties are actually different from the current property (i.e 874 // these are longhands while the current property is the shorthand) 875 // - all of the computed properties have defined values. In case the current property 876 // value contains CSS variables, then the computed properties will be missing and we 877 // want to avoid showing them. 878 return ( 879 this.prop.computed.some(c => c.name !== this.prop.name) && 880 !this.prop.computed.every(c => !c.value) 881 ); 882 } 883 884 get #shouldShowWarning() { 885 if (this.prop.name.startsWith("--")) { 886 return false; 887 } 888 889 return !this.editing && !this.isValid(); 890 } 891 892 get #shouldShowInactiveCssState() { 893 return ( 894 !this.editing && 895 !this.prop.overridden && 896 this.prop.enabled && 897 !!this.prop.getInactiveCssData() 898 ); 899 } 900 901 get #shouldShowFilterProperty() { 902 return ( 903 !this.editing && 904 this.isValid() && 905 this.prop.overridden && 906 !this.ruleEditor.rule.isUnmatched 907 ); 908 } 909 910 #createComputedExpander() { 911 if (this.expander) { 912 return; 913 } 914 915 // Click to expand the computed properties of the text property. 916 this.expander = this.doc.createElementNS(HTML_NS, "button"); 917 this.expander.ariaExpanded = false; 918 this.expander.classList.add("ruleview-expander", "theme-twisty"); 919 this.expander.title = SHORTHAND_EXPANDER_TOOLTIP; 920 921 this.expander.addEventListener("click", this.#onExpandClicked, { 922 capture: true, 923 signal: this.abortController.signal, 924 }); 925 926 this.container.insertBefore(this.expander, this.valueContainer); 927 } 928 929 #createComputedList() { 930 if (this.computed) { 931 return; 932 } 933 this.computed = this.doc.createElementNS(HTML_NS, "ul"); 934 this.computed.classList.add("ruleview-computedlist"); 935 this.element.insertBefore(this.computed, this.shorthandOverridden); 936 } 937 938 #createWarningIcon() { 939 if (this.warning) { 940 return; 941 } 942 943 this.warning = this.doc.createElementNS(HTML_NS, "div"); 944 this.warning.classList.add("ruleview-warning"); 945 this.warning.title = l10n("rule.warning.title"); 946 this.container.insertBefore( 947 this.warning, 948 this.invalidAtComputedValueTimeWarning || 949 this.inactiveCssState || 950 this.compatibilityState || 951 this.filterProperty 952 ); 953 } 954 955 #createInvalidAtComputedValueTimeIcon() { 956 if (this.invalidAtComputedValueTimeWarning) { 957 return; 958 } 959 960 this.invalidAtComputedValueTimeWarning = this.doc.createElementNS( 961 HTML_NS, 962 "div" 963 ); 964 this.invalidAtComputedValueTimeWarning.classList.add( 965 "ruleview-invalid-at-computed-value-time-warning" 966 ); 967 this.container.insertBefore( 968 this.invalidAtComputedValueTimeWarning, 969 this.inactiveCssState || this.compatibilityState || this.filterProperty 970 ); 971 } 972 973 #createInactiveCssWarningIcon() { 974 if (this.inactiveCssState) { 975 return; 976 } 977 978 this.inactiveCssState = this.doc.createElementNS(HTML_NS, "div"); 979 this.inactiveCssState.classList.add("ruleview-inactive-css-warning"); 980 this.container.insertBefore( 981 this.inactiveCssState, 982 this.compatibilityState || this.filterProperty 983 ); 984 } 985 986 #createCompatibilityWarningIcon() { 987 if (this.compatibilityState) { 988 return; 989 } 990 991 this.compatibilityState = this.doc.createElementNS(HTML_NS, "div"); 992 this.compatibilityState.classList.add("ruleview-compatibility-warning"); 993 this.container.insertBefore(this.compatibilityState, this.filterProperty); 994 } 995 996 #createFilterPropertyButton() { 997 if (this.filterProperty) { 998 return; 999 } 1000 1001 // Filter button that filters for the current property name and is 1002 // displayed when the property is overridden by another rule. 1003 this.filterProperty = this.doc.createElementNS(HTML_NS, "button"); 1004 this.filterProperty.classList.add("ruleview-overridden-rule-filter"); 1005 this.filterProperty.title = l10n("rule.filterProperty.title"); 1006 this.container.append(this.filterProperty); 1007 1008 this.filterProperty.addEventListener( 1009 "click", 1010 event => { 1011 this.ruleEditor.ruleView.setFilterStyles("`" + this.prop.name + "`"); 1012 event.stopPropagation(); 1013 }, 1014 { signal: this.abortController.signal } 1015 ); 1016 } 1017 1018 /** 1019 * Update the visibility of the enable checkbox, the warning indicator, the used 1020 * indicator and the filter property, as well as the overridden state of the property. 1021 */ 1022 updateUI = () => { 1023 if (this.prop.enabled) { 1024 this.enable.style.removeProperty("visibility"); 1025 } else { 1026 this.enable.style.visibility = "visible"; 1027 } 1028 1029 this.enable.checked = this.prop.enabled; 1030 1031 if (this.#shouldShowWarning) { 1032 this.element.classList.add("ruleview-invalid"); 1033 1034 if (!this.warning) { 1035 this.#createWarningIcon(); 1036 } else { 1037 this.warning.hidden = false; 1038 } 1039 this.warning.title = !this.#isNameValid() 1040 ? l10n("rule.warningName.title") 1041 : l10n("rule.warning.title"); 1042 } else { 1043 this.element.classList.remove("ruleview-invalid"); 1044 if (this.warning) { 1045 this.warning.hidden = true; 1046 } 1047 } 1048 1049 if (!this.editing && this.#isInvalidAtComputedValueTime()) { 1050 if (!this.invalidAtComputedValueTimeWarning) { 1051 this.#createInvalidAtComputedValueTimeIcon(); 1052 } 1053 this.invalidAtComputedValueTimeWarning.title = l10nFormatStr( 1054 "rule.warningInvalidAtComputedValueTime.title", 1055 `"${this.prop.getExpectedSyntax()}"` 1056 ); 1057 this.invalidAtComputedValueTimeWarning.hidden = false; 1058 } else if (this.invalidAtComputedValueTimeWarning) { 1059 this.invalidAtComputedValueTimeWarning.hidden = true; 1060 } 1061 1062 if (this.#shouldShowFilterProperty) { 1063 if (!this.filterProperty) { 1064 this.#createFilterPropertyButton(); 1065 } 1066 this.filterProperty.hidden = false; 1067 } else if (this.filterProperty) { 1068 this.filterProperty.hidden = true; 1069 } 1070 1071 if (this.#shouldShowComputedExpander) { 1072 if (!this.expander) { 1073 this.#createComputedExpander(); 1074 } 1075 this.expander.hidden = false; 1076 } else if (this.expander) { 1077 this.expander.hidden = true; 1078 } 1079 1080 if ( 1081 !this.editing && 1082 (this.prop.overridden || !this.prop.enabled || !this.prop.isKnownProperty) 1083 ) { 1084 this.element.classList.add("ruleview-overridden"); 1085 } else { 1086 this.element.classList.remove("ruleview-overridden"); 1087 } 1088 1089 this.#updateInactiveCssIndicator(); 1090 this.#updatePropertyCompatibilityIndicator(); 1091 }; 1092 1093 #updateInactiveCssIndicator() { 1094 const inactiveCssData = this.prop.getInactiveCssData(); 1095 1096 if ( 1097 this.editing || 1098 this.prop.overridden || 1099 !this.prop.enabled || 1100 !inactiveCssData 1101 ) { 1102 this.element.classList.remove("inactive-css"); 1103 if (this.inactiveCssState) { 1104 this.inactiveCssState.hidden = true; 1105 } 1106 } else { 1107 this.element.classList.add("inactive-css"); 1108 if (!this.inactiveCssState) { 1109 this.#createInactiveCssWarningIcon(); 1110 } else { 1111 this.inactiveCssState.hidden = false; 1112 } 1113 } 1114 } 1115 1116 async #updatePropertyCompatibilityIndicator() { 1117 const { isCompatible } = await this.prop.isCompatible(); 1118 1119 if (this.editing || isCompatible) { 1120 if (this.compatibilityState) { 1121 this.compatibilityState.hidden = true; 1122 } 1123 } else { 1124 if (!this.compatibilityState) { 1125 this.#createCompatibilityWarningIcon(); 1126 } 1127 this.compatibilityState.hidden = false; 1128 } 1129 } 1130 1131 /** 1132 * Update the indicator for computed styles. The computed styles themselves 1133 * are populated on demand, when they become visible. 1134 */ 1135 #updateComputed() { 1136 if (this.computed) { 1137 this.computed.replaceChildren(); 1138 } 1139 1140 if (this.#shouldShowComputedExpander) { 1141 if (!this.expander) { 1142 this.#createComputedExpander(); 1143 } 1144 this.expander.hidden = false; 1145 } else if (this.expander) { 1146 this.expander.hidden = true; 1147 } 1148 1149 this.#populatedComputed = false; 1150 if ( 1151 this.expander && 1152 this.expander.getAttribute("aria-expanded" === "true") 1153 ) { 1154 this.populateComputed(); 1155 } 1156 } 1157 1158 /** 1159 * Populate the list of computed styles. 1160 */ 1161 populateComputed() { 1162 if (this.#populatedComputed) { 1163 return; 1164 } 1165 this.#populatedComputed = true; 1166 1167 for (const computed of this.prop.computed) { 1168 // Don't bother to duplicate information already 1169 // shown in the text property. 1170 if (computed.name === this.prop.name) { 1171 continue; 1172 } 1173 1174 if (!this.computed) { 1175 this.#createComputedList(); 1176 } 1177 1178 // Store the computed style element for easy access when highlighting styles 1179 computed.element = this.#createComputedListItem( 1180 this.computed, 1181 computed, 1182 "ruleview-computed" 1183 ); 1184 } 1185 } 1186 1187 /** 1188 * Update the indicator for overridden shorthand styles. The shorthand 1189 * overridden styles themselves are populated on demand, when they 1190 * become visible. 1191 */ 1192 #updateShorthandOverridden() { 1193 if (this.shorthandOverridden) { 1194 this.shorthandOverridden.replaceChildren(); 1195 } 1196 1197 this.#populatedShorthandOverridden = false; 1198 this.#populateShorthandOverridden(); 1199 } 1200 1201 /** 1202 * Populate the list of overridden shorthand styles. 1203 */ 1204 #populateShorthandOverridden() { 1205 if ( 1206 this.#populatedShorthandOverridden || 1207 this.prop.overridden || 1208 !this.#shouldShowComputedExpander 1209 ) { 1210 return; 1211 } 1212 this.#populatedShorthandOverridden = true; 1213 1214 // Holds the viewers for the overridden shorthand properties. 1215 // will be populated in |#updateShorthandOverridden|. 1216 if (!this.shorthandOverridden) { 1217 this.shorthandOverridden = this.doc.createElementNS(HTML_NS, "ul"); 1218 this.shorthandOverridden.classList.add("ruleview-overridden-items"); 1219 this.element.append(this.shorthandOverridden); 1220 } 1221 1222 for (const computed of this.prop.computed) { 1223 // Don't display duplicate information or show properties 1224 // that are completely overridden. 1225 if (computed.name === this.prop.name || !computed.overridden) { 1226 continue; 1227 } 1228 1229 this.#createComputedListItem( 1230 this.shorthandOverridden, 1231 computed, 1232 "ruleview-overridden-item" 1233 ); 1234 } 1235 } 1236 1237 /** 1238 * Creates and populates a list item with the computed CSS property. 1239 */ 1240 #createComputedListItem(parentEl, computed, className) { 1241 const li = createChild(parentEl, "li", { 1242 class: className, 1243 }); 1244 1245 if (computed.overridden) { 1246 li.classList.add("ruleview-overridden"); 1247 } 1248 1249 const nameContainer = createChild(li, "span", { 1250 class: "ruleview-namecontainer", 1251 }); 1252 1253 createChild(nameContainer, "span", { 1254 class: "ruleview-propertyname theme-fg-color3", 1255 textContent: computed.name, 1256 }); 1257 appendText(nameContainer, ": "); 1258 1259 const outputParser = this.ruleView._outputParser; 1260 const frag = outputParser.parseCssProperty(computed.name, computed.value, { 1261 colorSwatchClass: "inspector-swatch inspector-colorswatch", 1262 urlClass: "theme-link", 1263 baseURI: this.#sheetHref, 1264 fontFamilyClass: "ruleview-font-family", 1265 }); 1266 1267 // Store the computed property value that was parsed for output 1268 computed.parsedValue = frag.textContent; 1269 1270 const propertyContainer = createChild(li, "span", { 1271 class: "ruleview-propertyvaluecontainer", 1272 }); 1273 1274 createChild(propertyContainer, "span", { 1275 class: "ruleview-propertyvalue theme-fg-color1", 1276 child: frag, 1277 }); 1278 appendText(propertyContainer, ";"); 1279 1280 return li; 1281 } 1282 1283 /** 1284 * Handle updates to the preference which disables/enables the feature to 1285 * edit size properties on drag. 1286 */ 1287 #onDraggablePreferenceChanged = () => { 1288 if (this.#isDraggableProperty(this.prop)) { 1289 this.#addDraggingCapability(); 1290 } else { 1291 this.#removeDraggingCapacity(); 1292 } 1293 }; 1294 1295 /** 1296 * Stop clicks propogating down the tree from the enable / disable checkbox. 1297 */ 1298 #onEnableClicked = event => { 1299 event.stopPropagation(); 1300 }; 1301 1302 /** 1303 * Handles clicks on the disabled property. 1304 */ 1305 #onEnableChanged = event => { 1306 this.prop.setEnabled(this.enable.checked); 1307 event.stopPropagation(); 1308 this.telemetry.recordEvent("edit_rule", "ruleview"); 1309 }; 1310 1311 /** 1312 * Handles clicks on the computed property expander. If the computed list is 1313 * open due to user expanding or style filtering, collapse the computed list 1314 * and close the expander. Otherwise, add user-open attribute which is used to 1315 * expand the computed list and tracks whether or not the computed list is 1316 * expanded by manually by the user. 1317 */ 1318 #onExpandClicked = event => { 1319 if (!this.computed) { 1320 // Holds the viewers for the computed properties. 1321 // will be populated in |#updateComputed|. 1322 this.#createComputedList(); 1323 } 1324 const isOpened = 1325 this.computed.hasAttribute("filter-open") || 1326 this.computed.hasAttribute("user-open"); 1327 1328 this.expander.setAttribute("aria-expanded", !isOpened); 1329 if (isOpened) { 1330 this.computed.removeAttribute("filter-open"); 1331 this.computed.removeAttribute("user-open"); 1332 if (this.shorthandOverridden) { 1333 this.shorthandOverridden.hidden = false; 1334 } 1335 this.#populateShorthandOverridden(); 1336 } else { 1337 this.computed.setAttribute("user-open", ""); 1338 if (this.shorthandOverridden) { 1339 this.shorthandOverridden.hidden = true; 1340 } 1341 this.populateComputed(); 1342 } 1343 1344 event.stopPropagation(); 1345 }; 1346 1347 /** 1348 * Expands the computed list when a computed property is matched by the style 1349 * filtering. The filter-open attribute is used to track whether or not the 1350 * computed list was toggled opened by the filter. 1351 */ 1352 expandForFilter() { 1353 if (!this.computed || !this.computed.hasAttribute("user-open")) { 1354 if (!this.expander) { 1355 this.#createComputedExpander(); 1356 } 1357 this.expander.hidden = false; 1358 this.expander.setAttribute("aria-expanded", "true"); 1359 1360 if (!this.computed) { 1361 this.#createComputedList(); 1362 } 1363 this.computed.setAttribute("filter-open", ""); 1364 this.populateComputed(); 1365 } 1366 } 1367 1368 /** 1369 * Collapses the computed list that was expanded by style filtering. 1370 */ 1371 collapseForFilter() { 1372 this.computed.removeAttribute("filter-open"); 1373 1374 if (!this.computed.hasAttribute("user-open") && this.expander) { 1375 this.expander.setAttribute("aria-expanded", "false"); 1376 } 1377 } 1378 1379 /** 1380 * Called when the property name's inplace editor is closed. 1381 * Ignores the change if the user pressed escape, otherwise 1382 * commits it. 1383 * 1384 * @param {string} value 1385 * The value contained in the editor. 1386 * @param {boolean} commit 1387 * True if the change should be applied. 1388 * @param {number} direction 1389 * The move focus direction number. 1390 */ 1391 #onNameDone = (value, commit, direction) => { 1392 const isNameUnchanged = 1393 (!commit && !this.ruleEditor.isEditing) || this.committed.name === value; 1394 if (this.prop.value && isNameUnchanged) { 1395 return; 1396 } 1397 1398 this.telemetry.recordEvent("edit_rule", "ruleview"); 1399 1400 // Remove a property if the name is empty 1401 if (!value.trim()) { 1402 this.remove(direction); 1403 return; 1404 } 1405 1406 const isVariable = value.startsWith("--"); 1407 1408 // Remove a property if: 1409 // - the property value is empty and is not a variable (empty variables are valid) 1410 // - and the property value is not about to be focused 1411 if ( 1412 !this.prop.value && 1413 !isVariable && 1414 direction !== Services.focus.MOVEFOCUS_FORWARD 1415 ) { 1416 this.remove(direction); 1417 return; 1418 } 1419 1420 // Adding multiple rules inside of name field overwrites the current 1421 // property with the first, then adds any more onto the property list. 1422 const properties = parseDeclarations(this.cssProperties.isKnown, value); 1423 1424 if (properties.length) { 1425 this.prop.setName(properties[0].name); 1426 this.committed.name = this.prop.name; 1427 1428 if (!this.prop.enabled) { 1429 this.prop.setEnabled(true); 1430 } 1431 1432 if (properties.length > 1) { 1433 this.prop.setValue(properties[0].value, properties[0].priority); 1434 this.ruleEditor.addProperties(properties.slice(1), this.prop); 1435 } 1436 } 1437 }; 1438 1439 /** 1440 * Remove property from style and the editors from DOM. 1441 * Begin editing next or previous available property given the focus 1442 * direction. 1443 * 1444 * @param {number} direction 1445 * The move focus direction number. 1446 */ 1447 remove(direction) { 1448 this.ruleEditor.rule.editClosestTextProperty(this.prop, direction); 1449 1450 this.prop.remove(); 1451 this.nameSpan.textProperty = null; 1452 this.valueSpan.textProperty = null; 1453 this.element.remove(); 1454 1455 this.destroy(); 1456 } 1457 1458 /** 1459 * Called when a value editor closes. If the user pressed escape, 1460 * revert to the value this property had before editing. 1461 * 1462 * @param {string} value 1463 * The value contained in the editor. 1464 * @param {boolean} commit 1465 * True if the change should be applied. 1466 * @param {number} direction 1467 * The move focus direction number. 1468 */ 1469 #onValueDone = (value = "", commit, direction) => { 1470 const parsedProperties = this.#getValueAndExtraProperties(value); 1471 const val = parseSingleValue( 1472 this.cssProperties.isKnown, 1473 parsedProperties.firstValue 1474 ); 1475 const isValueUnchanged = 1476 (!commit && !this.ruleEditor.isEditing) || 1477 (!parsedProperties.propertiesToAdd.length && 1478 this.committed.value === val.value && 1479 this.committed.priority === val.priority); 1480 1481 const isVariable = this.prop.name.startsWith("--"); 1482 1483 // If the value is not empty (or is an empty variable) and unchanged, 1484 // revert the property back to its original value and enabled or disabled state 1485 if ((value.trim() || isVariable) && isValueUnchanged) { 1486 const onPropertySet = this.ruleEditor.rule.previewPropertyValue( 1487 this.prop, 1488 val.value, 1489 val.priority 1490 ); 1491 this.rule.setPropertyEnabled(this.prop, this.prop.enabled); 1492 return onPropertySet; 1493 } 1494 1495 // Check if unit of value changed to add dragging feature 1496 if (this.#isDraggableProperty(val)) { 1497 this.#addDraggingCapability(); 1498 } else { 1499 this.#removeDraggingCapacity(); 1500 } 1501 1502 this.telemetry.recordEvent("edit_rule", "ruleview"); 1503 1504 // First, set this property value (common case, only modified a property) 1505 const onPropertySet = this.prop.setValue(val.value, val.priority); 1506 1507 if (!this.prop.enabled) { 1508 this.prop.setEnabled(true); 1509 } 1510 1511 this.committed.value = this.prop.value; 1512 this.committed.priority = this.prop.priority; 1513 1514 // If needed, add any new properties after this.prop. 1515 this.ruleEditor.addProperties(parsedProperties.propertiesToAdd, this.prop); 1516 1517 // If the input value is empty and is not a variable (empty variables are valid), 1518 // and the focus is moving forward to the next editable field, 1519 // then remove the whole property. 1520 // A timeout is used here to accurately check the state, since the inplace 1521 // editor `done` and `destroy` events fire before the next editor 1522 // is focused. 1523 if ( 1524 !value.trim() && 1525 !isVariable && 1526 direction !== Services.focus.MOVEFOCUS_BACKWARD 1527 ) { 1528 setTimeout(() => { 1529 if (!this.editing) { 1530 this.remove(direction); 1531 } 1532 }, 0); 1533 } 1534 1535 return onPropertySet; 1536 }; 1537 1538 /** 1539 * Called when the swatch editor wants to commit a value change. 1540 */ 1541 #onSwatchCommit = () => { 1542 this.#onValueDone(this.valueSpan.textContent, true); 1543 this.update(); 1544 }; 1545 1546 /** 1547 * Called when the swatch editor wants to preview a value change. 1548 */ 1549 #onSwatchPreview = () => { 1550 this.#previewValue(this.valueSpan.textContent); 1551 }; 1552 1553 /** 1554 * Called when the swatch editor closes from an ESC. Revert to the original 1555 * value of this property before editing. 1556 */ 1557 #onSwatchRevert = () => { 1558 this.#previewValue(this.prop.value, true); 1559 this.update(); 1560 }; 1561 1562 /** 1563 * Parse a value string and break it into pieces, starting with the 1564 * first value, and into an array of additional properties (if any). 1565 * 1566 * Example: Calling with "red; width: 100px" would return 1567 * { firstValue: "red", propertiesToAdd: [{ name: "width", value: "100px" }] } 1568 * 1569 * @param {string} value 1570 * The string to parse 1571 * @return {object} An object with the following properties: 1572 * firstValue: A string containing a simple value, like 1573 * "red" or "100px!important" 1574 * propertiesToAdd: An array with additional properties, following the 1575 * parseDeclarations format of {name,value,priority} 1576 */ 1577 #getValueAndExtraProperties(value) { 1578 // The inplace editor will prevent manual typing of multiple properties, 1579 // but we need to deal with the case during a paste event. 1580 // Adding multiple properties inside of value editor sets value with the 1581 // first, then adds any more onto the property list (below this property). 1582 let firstValue = value; 1583 let propertiesToAdd = []; 1584 1585 const properties = parseDeclarations(this.cssProperties.isKnown, value); 1586 1587 // Check to see if the input string can be parsed as multiple properties 1588 if (properties.length) { 1589 // Get the first property value (if any), and any remaining 1590 // properties (if any) 1591 if (!properties[0].name && properties[0].value) { 1592 firstValue = properties[0].value; 1593 propertiesToAdd = properties.slice(1); 1594 } else if (properties[0].name && properties[0].value) { 1595 // In some cases, the value could be a property:value pair 1596 // itself. Join them as one value string and append 1597 // potentially following properties 1598 firstValue = properties[0].name + ": " + properties[0].value; 1599 propertiesToAdd = properties.slice(1); 1600 } 1601 } 1602 1603 return { 1604 propertiesToAdd, 1605 firstValue, 1606 }; 1607 } 1608 1609 /** 1610 * Live preview this property, without committing changes. 1611 * 1612 * @param {string} value 1613 * The value to set the current property to. 1614 * @param {boolean} reverting 1615 * True if we're reverting the previously previewed value 1616 */ 1617 #previewValue = (value, reverting = false) => { 1618 // Since function call is debounced, we need to make sure we are still 1619 // editing, and any selector modifications have been completed 1620 if (!reverting && (!this.editing || this.ruleEditor.isEditing)) { 1621 return; 1622 } 1623 1624 const val = parseSingleValue(this.cssProperties.isKnown, value); 1625 this.ruleEditor.rule.previewPropertyValue( 1626 this.prop, 1627 val.value, 1628 val.priority 1629 ); 1630 }; 1631 1632 /** 1633 * Check if the event passed has a "small increment" modifier 1634 * Alt on macosx and ctrl on other OSs 1635 * 1636 * @param {KeyboardEvent} event 1637 * @returns {boolean} 1638 */ 1639 #hasSmallIncrementModifier(event) { 1640 const modifier = 1641 lazy.AppConstants.platform === "macosx" ? "altKey" : "ctrlKey"; 1642 return event[modifier] === true; 1643 } 1644 1645 /** 1646 * Parses the value to check if it is a dimension 1647 * e.g. if the input is "128px" it will return an object like 1648 * { groups: { value: "128", unit: "px"}} 1649 * 1650 * @param {string} value 1651 * @returns {object | null} 1652 */ 1653 #parseDimension(value) { 1654 // The regex handles values like +1, -1, 1e4, .4, 1.3e-4, 1.567 1655 const cssDimensionRegex = 1656 /^(?<value>[+-]?(\d*\.)?\d+(e[+-]?\d+)?)(?<unit>(%|[a-zA-Z]+))$/; 1657 return value.match(cssDimensionRegex); 1658 } 1659 1660 /** 1661 * Check if a textProperty value is supported to add the dragging feature 1662 * 1663 * @param {TextProperty} textProperty 1664 * @returns {boolean} 1665 */ 1666 #isDraggableProperty(textProperty) { 1667 // Check if the feature is explicitly disabled. 1668 if (!this.ruleView.draggablePropertiesEnabled) { 1669 return false; 1670 } 1671 // temporary way of fixing the bug when editing inline styles 1672 // otherwise the textPropertyEditor object is destroyed on each value edit 1673 // See Bug 1755024 1674 if (this.rule.domRule.type == ELEMENT_STYLE) { 1675 return false; 1676 } 1677 1678 const nbValues = textProperty.value.split(" ").length; 1679 if (nbValues > 1) { 1680 // we do not support values like "1px solid red" yet 1681 // See 1755025 1682 return false; 1683 } 1684 1685 const dimensionMatchObj = this.#parseDimension(textProperty.value); 1686 return !!dimensionMatchObj; 1687 } 1688 1689 #draggingOnPointerDown = event => { 1690 if (!canPointerEventDrag(event)) { 1691 return; 1692 } 1693 1694 this.#isDragging = true; 1695 this.valueSpan.setPointerCapture(event.pointerId); 1696 this.#capturingPointerId = event.pointerId; 1697 this.#draggingController = new AbortController(); 1698 const { signal } = this.#draggingController; 1699 1700 // turn off user-select in CSS when we drag 1701 this.valueSpan.classList.add(IS_DRAGGING_CLASSNAME); 1702 1703 const dimensionObj = this.#parseDimension(this.prop.value); 1704 const { value, unit } = dimensionObj.groups; 1705 // `pointerdown.screenX` may be fractional value and we will compare it 1706 // with `mousemove.screenX` which is always integer value and the difference 1707 // will be used to compute the new value. For making the difference always 1708 // integer, we need to convert it to integer value with the same as 1709 // MouseEvent does. 1710 // https://searchfox.org/mozilla-central/rev/7857ea04d142f2abc0d777085f9e54526c7cedf9/dom/events/MouseEvent.cpp#314 1711 // https://searchfox.org/mozilla-central/rev/7857ea04d142f2abc0d777085f9e54526c7cedf9/gfx/2d/Point.h#85-86 1712 const intScreenX = Math.floor(event.screenX + 0.5); 1713 this.#draggingValueCache = { 1714 isInDeadzone: true, 1715 previousScreenX: intScreenX, 1716 value: parseFloat(value), 1717 unit, 1718 }; 1719 1720 // "pointermove" is fired when the button state is changed too. Therefore, 1721 // we should listen to "mousemove" to handle the pointer position changes. 1722 this.valueSpan.addEventListener("mousemove", this.#draggingOnMouseMove, { 1723 signal, 1724 }); 1725 this.valueSpan.addEventListener("pointerup", this.#draggingOnPointerUp, { 1726 signal, 1727 }); 1728 this.valueSpan.addEventListener("keydown", this.#draggingOnKeydown, { 1729 signal, 1730 }); 1731 }; 1732 1733 #draggingOnMouseMove = throttle(event => { 1734 if (!this.#isDragging) { 1735 return; 1736 } 1737 1738 const { isInDeadzone, previousScreenX } = this.#draggingValueCache; 1739 let deltaX = event.screenX - previousScreenX; 1740 1741 // If `isInDeadzone` is still true, the user has not previously left the deadzone. 1742 if (isInDeadzone) { 1743 // If the mouse is still in the deadzone, bail out immediately. 1744 if (Math.abs(deltaX) < DRAGGING_DEADZONE_DISTANCE) { 1745 return; 1746 } 1747 1748 // Otherwise, remove the DRAGGING_DEADZONE_DISTANCE from the current deltaX, so that 1749 // the value does not update too abruptly. 1750 deltaX = 1751 Math.sign(deltaX) * (Math.abs(deltaX) - DRAGGING_DEADZONE_DISTANCE); 1752 1753 // Update the state to remember the user is out of the deadzone. 1754 this.#draggingValueCache.isInDeadzone = false; 1755 } 1756 1757 let draggingSpeed = DEFAULT_DRAGGING_SPEED; 1758 if (event.shiftKey) { 1759 draggingSpeed = FAST_DRAGGING_SPEED; 1760 } else if (this.#hasSmallIncrementModifier(event)) { 1761 draggingSpeed = SLOW_DRAGGING_SPEED; 1762 } 1763 1764 const delta = deltaX * draggingSpeed; 1765 this.#draggingValueCache.previousScreenX = event.screenX; 1766 this.#draggingValueCache.value += delta; 1767 1768 if (delta == 0) { 1769 return; 1770 } 1771 1772 const { value, unit } = this.#draggingValueCache; 1773 // We use toFixed to avoid the case where value is too long, 9.00001px for example 1774 const roundedValue = Number.isInteger(value) ? value : value.toFixed(1); 1775 this.prop 1776 .setValue(roundedValue + unit, this.prop.priority) 1777 .then(() => this.ruleView.emitForTests("property-updated-by-dragging")); 1778 this.#hasDragged = true; 1779 }, 30); 1780 1781 #draggingOnPointerUp = () => { 1782 if (!this.#isDragging) { 1783 return; 1784 } 1785 if (this.#hasDragged) { 1786 this.committed.value = this.prop.value; 1787 this.prop.setEnabled(true); 1788 } 1789 this.#onStopDragging(); 1790 }; 1791 1792 #draggingOnKeydown = event => { 1793 if (event.key == "Escape") { 1794 this.prop.setValue(this.committed.value, this.committed.priority); 1795 this.#onStopDragging(); 1796 event.preventDefault(); 1797 } 1798 }; 1799 1800 #onStopDragging() { 1801 // childHasDragged is used to stop the propagation of a click event when we 1802 // release the mouse in the ruleview. 1803 // The click event is not emitted when we have a pending click on the text property. 1804 if (this.#hasDragged && !this.#hasPendingClick) { 1805 this.ruleView.childHasDragged = true; 1806 } 1807 this.#isDragging = false; 1808 this.#hasDragged = false; 1809 this.#draggingValueCache = null; 1810 if (this.#capturingPointerId !== null) { 1811 this.#capturingPointerId = null; 1812 try { 1813 this.valueSpan.releasePointerCapture(this.#capturingPointerId); 1814 } catch (e) { 1815 // Ignore exception even if the pointerId has already been invalidated 1816 // before the capture has already been released implicitly. 1817 } 1818 } 1819 this.valueSpan.classList.remove(IS_DRAGGING_CLASSNAME); 1820 this.#draggingController.abort(); 1821 } 1822 1823 /** 1824 * add event listeners to add the ability to modify any size value 1825 * by dragging the mouse horizontally 1826 */ 1827 #addDraggingCapability() { 1828 if (this.valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME)) { 1829 return; 1830 } 1831 this.valueSpan.classList.add(DRAGGABLE_VALUE_CLASSNAME); 1832 this.valueSpan.addEventListener( 1833 "pointerdown", 1834 this.#draggingOnPointerDown, 1835 { passive: true } 1836 ); 1837 } 1838 1839 #removeDraggingCapacity() { 1840 if (!this.valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME)) { 1841 return; 1842 } 1843 this.#draggingController = null; 1844 this.valueSpan.classList.remove(DRAGGABLE_VALUE_CLASSNAME); 1845 this.valueSpan.removeEventListener( 1846 "pointerdown", 1847 this.#draggingOnPointerDown, 1848 { passive: true } 1849 ); 1850 } 1851 1852 /** 1853 * Validate this property. Does it make sense for this value to be assigned 1854 * to this property name? This does not apply the property value 1855 * 1856 * @return {boolean} true if the property name + value pair is valid, false otherwise. 1857 */ 1858 isValid() { 1859 return this.prop.isValid(); 1860 } 1861 1862 /** 1863 * Validate the name of this property. 1864 * 1865 * @return {boolean} true if the property name is valid, false otherwise. 1866 */ 1867 #isNameValid() { 1868 return this.prop.isNameValid(); 1869 } 1870 1871 #isInvalidAtComputedValueTime() { 1872 return this.prop.isInvalidAtComputedValueTime(); 1873 } 1874 1875 /** 1876 * Display grid-template-area value strings each on their own line 1877 * to display it in an ascii-art style matrix 1878 */ 1879 #formatGridTemplateAreasValue() { 1880 this.valueSpan.classList.add("ruleview-propertyvalue-break-spaces"); 1881 1882 let quoteSymbolsUsed = []; 1883 1884 const getQuoteSymbolsUsed = cssValue => { 1885 const regex = /\"|\'/g; 1886 const found = cssValue.match(regex); 1887 quoteSymbolsUsed = found.filter((_, i) => i % 2 === 0); 1888 }; 1889 1890 getQuoteSymbolsUsed(this.valueSpan.innerText); 1891 1892 this.valueSpan.innerText = this.valueSpan.innerText 1893 .split('"') 1894 .filter(s => s !== "") 1895 .map(s => s.split("'")) 1896 .flat() 1897 .map(s => s.trim().replace(/\s+/g, " ")) 1898 .filter(s => s.length) 1899 .map(line => line.split(" ")) 1900 .map((line, i, lines) => 1901 line.map((col, j) => 1902 col.padEnd(Math.max(...lines.map(l => l[j]?.length || 0)), " ") 1903 ) 1904 ) 1905 .map( 1906 (line, i) => 1907 `\n${quoteSymbolsUsed[i]}` + line.join(" ") + quoteSymbolsUsed[i] 1908 ) 1909 .join(" "); 1910 } 1911 1912 destroy() { 1913 if (this.#colorSwatchSpans && this.#colorSwatchSpans.length) { 1914 for (const span of this.#colorSwatchSpans) { 1915 this.ruleView.tooltips.getTooltip("colorPicker").removeSwatch(span); 1916 } 1917 } 1918 1919 if (this.angleSwatchSpans && this.angleSwatchSpans.length) { 1920 for (const span of this.angleSwatchSpans) { 1921 span.removeEventListener("unit-change", this.#onSwatchCommit); 1922 this.ruleView.tooltips.getTooltip("filterEditor").removeSwatch(span); 1923 } 1924 } 1925 1926 if (this.#bezierSwatchSpans && this.#bezierSwatchSpans.length) { 1927 for (const span of this.#bezierSwatchSpans) { 1928 this.ruleView.tooltips.getTooltip("cubicBezier").removeSwatch(span); 1929 } 1930 } 1931 1932 if (this.#linearEasingSwatchSpans && this.#linearEasingSwatchSpans.length) { 1933 for (const span of this.#linearEasingSwatchSpans) { 1934 this.ruleView.tooltips 1935 .getTooltip("linearEaseFunction") 1936 .removeSwatch(span); 1937 } 1938 } 1939 1940 if (this.abortController) { 1941 this.abortController.abort(); 1942 this.abortController = null; 1943 } 1944 1945 this.#elementsWithPendingClicks.delete(this.valueSpan); 1946 } 1947 } 1948 1949 module.exports = TextPropertyEditor;