/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const { l10n } = require("resource://devtools/shared/inspector/css-logic.js"); const { InplaceEditor, editableField, } = require("resource://devtools/client/shared/inplace-editor.js"); const { createChild, appendText, advanceValidate, blurOnMultipleProperties, } = require("resource://devtools/client/inspector/shared/utils.js"); const { throttle } = require("resource://devtools/shared/throttle.js"); const { style: { ELEMENT_STYLE }, } = require("resource://devtools/shared/constants.js"); loader.lazyRequireGetter( this, "openContentLink", "resource://devtools/client/shared/link.js", true ); loader.lazyRequireGetter( this, ["parseDeclarations", "parseSingleValue"], "resource://devtools/shared/css/parsing-utils.js", true ); loader.lazyRequireGetter( this, "findCssSelector", "resource://devtools/shared/inspector/css-logic.js", true ); const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AppConstants: "resource://gre/modules/AppConstants.sys.mjs", }); const HTML_NS = "http://www.w3.org/1999/xhtml"; const inlineCompatibilityWarningEnabled = Services.prefs.getBoolPref( "devtools.inspector.ruleview.inline-compatibility-warning.enabled" ); const SHARED_SWATCH_CLASS = "ruleview-swatch"; const COLOR_SWATCH_CLASS = "ruleview-colorswatch"; const BEZIER_SWATCH_CLASS = "ruleview-bezierswatch"; const LINEAR_EASING_SWATCH_CLASS = "ruleview-lineareasingswatch"; const FILTER_SWATCH_CLASS = "ruleview-filterswatch"; const ANGLE_SWATCH_CLASS = "ruleview-angleswatch"; const FONT_FAMILY_CLASS = "ruleview-font-family"; const SHAPE_SWATCH_CLASS = "ruleview-shapeswatch"; /* * An actionable element is an element which on click triggers a specific action * (e.g. shows a color tooltip, opens a link, …). */ const ACTIONABLE_ELEMENTS_SELECTORS = [ `.${COLOR_SWATCH_CLASS}`, `.${BEZIER_SWATCH_CLASS}`, `.${LINEAR_EASING_SWATCH_CLASS}`, `.${FILTER_SWATCH_CLASS}`, `.${ANGLE_SWATCH_CLASS}`, "a", ]; /* * Speeds at which we update the value when the user is dragging its mouse * over a value. */ const SLOW_DRAGGING_SPEED = 0.1; const DEFAULT_DRAGGING_SPEED = 1; const FAST_DRAGGING_SPEED = 10; // Deadzone in pixels where dragging should not update the value. const DRAGGING_DEADZONE_DISTANCE = 5; const DRAGGABLE_VALUE_CLASSNAME = "ruleview-propertyvalue-draggable"; const IS_DRAGGING_CLASSNAME = "ruleview-propertyvalue-dragging"; // In order to highlight the used fonts in font-family properties, we // retrieve the list of used fonts from the server. That always // returns the actually used font family name(s). If the property's // authored value is sans-serif for instance, the used font might be // arial instead. So we need the list of all generic font family // names to underline those when we find them. const GENERIC_FONT_FAMILIES = [ "serif", "sans-serif", "cursive", "fantasy", "monospace", "system-ui", ]; /** * TextPropertyEditor is responsible for the following: * Owns a TextProperty object. * Manages changes to the TextProperty. * Can be expanded to display computed properties. * Can mark a property disabled or enabled. * * @param {RuleEditor} ruleEditor * The rule editor that owns this TextPropertyEditor. * @param {TextProperty} property * The text property to edit. */ function TextPropertyEditor(ruleEditor, property) { this.ruleEditor = ruleEditor; this.ruleView = this.ruleEditor.ruleView; this.cssProperties = this.ruleView.cssProperties; this.doc = this.ruleEditor.doc; this.popup = this.ruleView.popup; this.prop = property; this.prop.editor = this; this.browserWindow = this.doc.defaultView.top; this._populatedComputed = false; this._hasPendingClick = false; this._clickedElementOptions = null; this.toolbox = this.ruleView.inspector.toolbox; this.telemetry = this.toolbox.telemetry; this._isDragging = false; this._hasDragged = false; this._draggingController = null; this._draggingValueCache = null; this.getGridlineNames = this.getGridlineNames.bind(this); this.update = this.update.bind(this); this.updatePropertyState = this.updatePropertyState.bind(this); this._onDraggablePreferenceChanged = this._onDraggablePreferenceChanged.bind(this); this._onEnableChanged = this._onEnableChanged.bind(this); this._onEnableClicked = this._onEnableClicked.bind(this); this._onExpandClicked = this._onExpandClicked.bind(this); this._onNameDone = this._onNameDone.bind(this); this._onStartEditing = this._onStartEditing.bind(this); this._onSwatchCommit = this._onSwatchCommit.bind(this); this._onSwatchPreview = this._onSwatchPreview.bind(this); this._onSwatchRevert = this._onSwatchRevert.bind(this); this._onValidate = this.ruleView.debounce(this._previewValue, 10, this); this._onValueDone = this._onValueDone.bind(this); this._draggingOnMouseDown = this._draggingOnMouseDown.bind(this); this._draggingOnMouseMove = throttle(this._draggingOnMouseMove, 30, this); this._draggingOnMouseUp = this._draggingOnMouseUp.bind(this); this._draggingOnKeydown = this._draggingOnKeydown.bind(this); this._create(); this.update(); } TextPropertyEditor.prototype = { /** * Boolean indicating if the name or value is being currently edited. */ get editing() { return ( !!( this.nameSpan.inplaceEditor || this.valueSpan.inplaceEditor || this.ruleView.tooltips.isEditing ) || this.popup.isOpen ); }, /** * Get the rule to the current text property */ get rule() { return this.prop.rule; }, // Exposed for tests. get _DRAGGING_DEADZONE_DISTANCE() { return DRAGGING_DEADZONE_DISTANCE; }, /** * Create the property editor's DOM. */ _create() { this.element = this.doc.createElementNS(HTML_NS, "li"); this.element.classList.add("ruleview-property"); this.element.dataset.declarationId = this.prop.id; this.element._textPropertyEditor = this; this.container = createChild(this.element, "div", { class: "ruleview-propertycontainer", }); // The enable checkbox will disable or enable the rule. this.enable = createChild(this.container, "input", { type: "checkbox", class: "ruleview-enableproperty", "aria-labelledby": this.prop.id, tabindex: "-1", }); this.nameContainer = createChild(this.container, "span", { class: "ruleview-namecontainer", }); // Property name, editable when focused. Property name // is committed when the editor is unfocused. this.nameSpan = createChild(this.nameContainer, "span", { class: "ruleview-propertyname theme-fg-color3", tabindex: this.ruleEditor.isEditable ? "0" : "-1", id: this.prop.id, }); appendText(this.nameContainer, ": "); // Click to expand the computed properties of the text property. this.expander = createChild(this.container, "span", { class: "ruleview-expander theme-twisty", }); this.expander.addEventListener("click", this._onExpandClicked, true); // Create a span that will hold the property and semicolon. // Use this span to create a slightly larger click target // for the value. this.valueContainer = createChild(this.container, "span", { class: "ruleview-propertyvaluecontainer", }); // Property value, editable when focused. Changes to the // property value are applied as they are typed, and reverted // if the user presses escape. this.valueSpan = createChild(this.valueContainer, "span", { class: "ruleview-propertyvalue theme-fg-color1", tabindex: this.ruleEditor.isEditable ? "0" : "-1", }); // Storing the TextProperty on the elements for easy access // (for instance by the tooltip) this.valueSpan.textProperty = this.prop; this.nameSpan.textProperty = this.prop; appendText(this.valueContainer, ";"); this.warning = createChild(this.container, "div", { class: "ruleview-warning", hidden: "", title: l10n("rule.warning.title"), }); this.unusedState = createChild(this.container, "div", { class: "ruleview-unused-warning", hidden: "", }); if (inlineCompatibilityWarningEnabled) { this.compatibilityState = createChild(this.container, "div", { class: "ruleview-compatibility-warning", hidden: "", }); } // Filter button that filters for the current property name and is // displayed when the property is overridden by another rule. this.filterProperty = createChild(this.container, "div", { class: "ruleview-overridden-rule-filter", hidden: "", title: l10n("rule.filterProperty.title"), }); this.filterProperty.addEventListener("click", event => { this.ruleEditor.ruleView.setFilterStyles("`" + this.prop.name + "`"); event.stopPropagation(); }); // Holds the viewers for the computed properties. // will be populated in |_updateComputed|. this.computed = createChild(this.element, "ul", { class: "ruleview-computedlist", }); // Holds the viewers for the overridden shorthand properties. // will be populated in |_updateShorthandOverridden|. this.shorthandOverridden = createChild(this.element, "ul", { class: "ruleview-overridden-items", }); // Only bind event handlers if the rule is editable. if (this.ruleEditor.isEditable) { this.enable.addEventListener("click", this._onEnableClicked, true); this.enable.addEventListener("change", this._onEnableChanged, true); this.nameContainer.addEventListener("click", event => { // Clicks within the name shouldn't propagate any further. event.stopPropagation(); // Forward clicks on nameContainer to the editable nameSpan if (event.target === this.nameContainer) { this.nameSpan.click(); } }); editableField({ start: this._onStartEditing, element: this.nameSpan, done: this._onNameDone, destroy: this.updatePropertyState, advanceChars: ":", contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY, popup: this.popup, cssProperties: this.cssProperties, }); // Auto blur name field on multiple CSS rules get pasted in. this.nameContainer.addEventListener( "paste", blurOnMultipleProperties(this.cssProperties) ); this.valueContainer.addEventListener("click", event => { // Clicks within the value shouldn't propagate any further. event.stopPropagation(); // Forward clicks on valueContainer to the editable valueSpan if (event.target === this.valueContainer) { this.valueSpan.click(); } }); // The mousedown event could trigger a blur event on nameContainer, which // will trigger a call to the update function. The update function clears // valueSpan's markup. Thus the regular click event does not bubble up, and // listener's callbacks are not called. // So we need to remember where the user clicks in order to re-trigger the click // after the valueSpan's markup is re-populated. We only need to track this for // valueSpan's child elements, because direct click on valueSpan will always // trigger a click event. this.valueSpan.addEventListener("mousedown", event => { const clickedEl = event.target; if (clickedEl === this.valueSpan) { return; } this._hasPendingClick = true; const matchedSelector = ACTIONABLE_ELEMENTS_SELECTORS.find(selector => clickedEl.matches(selector) ); if (matchedSelector) { const similarElements = [ ...this.valueSpan.querySelectorAll(matchedSelector), ]; this._clickedElementOptions = { selector: matchedSelector, index: similarElements.indexOf(clickedEl), }; } }); this.valueSpan.addEventListener("mouseup", event => { // if we have dragged, we will handle the pending click in _draggingOnMouseUp instead if (this._hasDragged) { return; } this._clickedElementOptions = null; this._hasPendingClick = false; }); this.valueSpan.addEventListener("click", event => { const target = event.target; if (target.nodeName === "a") { event.stopPropagation(); event.preventDefault(); openContentLink(target.href); } }); this.ruleView.on( "draggable-preference-updated", this._onDraggablePreferenceChanged ); if (this._isDraggableProperty(this.prop)) { this._addDraggingCapability(); } editableField({ start: this._onStartEditing, element: this.valueSpan, done: this._onValueDone, destroy: this.update, validate: this._onValidate, advanceChars: advanceValidate, contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE, property: this.prop, defaultIncrement: this.prop.name === "opacity" ? 0.1 : 1, popup: this.popup, multiline: true, maxWidth: () => this.container.getBoundingClientRect().width, cssProperties: this.cssProperties, cssVariables: this.rule.elementStyle.variablesMap.get(this.rule.pseudoElement) || [], getGridLineNames: this.getGridlineNames, showSuggestCompletionOnEmpty: true, }); } }, /** * Get the grid line names of the grid that the currently selected element is * contained in. * * @return {Object} Contains the names of the cols and rows as arrays * {cols: [], rows: []}. */ async getGridlineNames() { const gridLineNames = { cols: [], rows: [] }; const layoutInspector = await this.ruleView.inspector.walker.getLayoutInspector(); const gridFront = await layoutInspector.getCurrentGrid( this.ruleView.inspector.selection.nodeFront ); if (gridFront) { const gridFragments = gridFront.gridFragments; for (const gridFragment of gridFragments) { for (const rowLine of gridFragment.rows.lines) { // We specifically ignore implicit line names created from implicitly named // areas. This is because showing implicit line names can be confusing for // designers who may have used a line name with "-start" or "-end" and created // an implicitly named grid area without meaning to. let gridArea; for (const name of rowLine.names) { const rowLineName = name.substring(0, name.lastIndexOf("-start")) || name.substring(0, name.lastIndexOf("-end")); gridArea = gridFragment.areas.find( area => area.name === rowLineName ); if ( rowLine.type === "implicit" && gridArea && gridArea.type === "implicit" ) { continue; } gridLineNames.rows.push(name); } } for (const colLine of gridFragment.cols.lines) { let gridArea; for (const name of colLine.names) { const colLineName = name.substring(0, name.lastIndexOf("-start")) || name.substring(0, name.lastIndexOf("-end")); gridArea = gridFragment.areas.find( area => area.name === colLineName ); if ( colLine.type === "implicit" && gridArea && gridArea.type === "implicit" ) { continue; } gridLineNames.cols.push(name); } } } } // Emit message for test files this.ruleView.inspector.emit("grid-line-names-updated"); return gridLineNames; }, /** * Get the path from which to resolve requests for this * rule's stylesheet. * * @return {String} the stylesheet's href. */ get sheetHref() { const domRule = this.rule.domRule; if (domRule) { return domRule.href || domRule.nodeHref; } return undefined; }, /** * Populate the span based on changes to the TextProperty. */ // eslint-disable-next-line complexity update() { if (this.ruleView.isDestroyed) { return; } this.updatePropertyState(); const name = this.prop.name; this.nameSpan.textContent = name; // Combine the property's value and priority into one string for // the value. const store = this.rule.elementStyle.store; let val = store.userProperties.getProperty( this.rule.domRule, name, this.prop.value ); if (this.prop.priority) { val += " !" + this.prop.priority; } const propDirty = store.userProperties.contains(this.rule.domRule, name); if (propDirty) { this.element.setAttribute("dirty", ""); } else { this.element.removeAttribute("dirty"); } const outputParser = this.ruleView._outputParser; const parserOptions = { angleClass: "ruleview-angle", angleSwatchClass: SHARED_SWATCH_CLASS + " " + ANGLE_SWATCH_CLASS, bezierClass: "ruleview-bezier", bezierSwatchClass: SHARED_SWATCH_CLASS + " " + BEZIER_SWATCH_CLASS, colorClass: "ruleview-color", colorSwatchClass: SHARED_SWATCH_CLASS + " " + COLOR_SWATCH_CLASS, filterClass: "ruleview-filter", filterSwatchClass: SHARED_SWATCH_CLASS + " " + FILTER_SWATCH_CLASS, flexClass: "ruleview-flex js-toggle-flexbox-highlighter", gridClass: "ruleview-grid js-toggle-grid-highlighter", linearEasingClass: "ruleview-lineareasing", linearEasingSwatchClass: SHARED_SWATCH_CLASS + " " + LINEAR_EASING_SWATCH_CLASS, shapeClass: "ruleview-shape", shapeSwatchClass: SHAPE_SWATCH_CLASS, // Only ask the parser to convert colors to the default color type specified by the // user if the property hasn't been changed yet. defaultColorType: !propDirty, urlClass: "theme-link", fontFamilyClass: FONT_FAMILY_CLASS, baseURI: this.sheetHref, unmatchedVariableClass: "ruleview-unmatched-variable", matchedVariableClass: "ruleview-variable", getVariableValue: varName => this.rule.elementStyle.getVariable(varName, this.rule.pseudoElement), }; const frag = outputParser.parseCssProperty(name, val, parserOptions); // Save the initial value as the last committed value, // for restoring after pressing escape. if (!this.committed) { this.committed = { name, value: frag.textContent, priority: this.prop.priority, }; } // Save focused element inside value span if one exists before wiping the innerHTML let focusedElSelector = null; if (this.valueSpan.contains(this.doc.activeElement)) { focusedElSelector = findCssSelector(this.doc.activeElement); } this.valueSpan.innerHTML = ""; this.valueSpan.appendChild(frag); if ( this.valueSpan.textProperty?.name === "grid-template-areas" && this.isValid() && (this.valueSpan.innerText.includes(`"`) || this.valueSpan.innerText.includes(`'`)) ) { this._formatGridTemplateAreasValue(); } this.ruleView.emit("property-value-updated", { rule: this.prop.rule, property: name, value: val, }); // Highlight the currently used font in font-family properties. // If we cannot find a match, highlight the first generic family instead. const fontFamilySpans = this.valueSpan.querySelectorAll( "." + FONT_FAMILY_CLASS ); if (fontFamilySpans.length && this.prop.enabled && !this.prop.overridden) { this.rule.elementStyle .getUsedFontFamilies() .then(families => { const usedFontFamilies = families.map(font => font.toLowerCase()); let foundMatchingFamily = false; let firstGenericSpan = null; for (const span of fontFamilySpans) { const authoredFont = span.textContent.toLowerCase(); if ( !firstGenericSpan && GENERIC_FONT_FAMILIES.includes(authoredFont) ) { firstGenericSpan = span; } if (usedFontFamilies.includes(authoredFont)) { span.classList.add("used-font"); foundMatchingFamily = true; } } if (!foundMatchingFamily && firstGenericSpan) { firstGenericSpan.classList.add("used-font"); } this.ruleView.emit("font-highlighted", this.valueSpan); }) .catch(e => console.error("Could not get the list of font families", e) ); } // Attach the color picker tooltip to the color swatches this._colorSwatchSpans = this.valueSpan.querySelectorAll( "." + COLOR_SWATCH_CLASS ); if (this.ruleEditor.isEditable) { for (const span of this._colorSwatchSpans) { // Adding this swatch to the list of swatches our colorpicker // knows about this.ruleView.tooltips.getTooltip("colorPicker").addSwatch(span, { onShow: this._onStartEditing, onPreview: this._onSwatchPreview, onCommit: this._onSwatchCommit, onRevert: this._onSwatchRevert, }); const title = l10n("rule.colorSwatch.tooltip"); span.setAttribute("title", title); span.dataset.propertyName = this.nameSpan.textContent; } } // Attach the cubic-bezier tooltip to the bezier swatches this._bezierSwatchSpans = this.valueSpan.querySelectorAll( "." + BEZIER_SWATCH_CLASS ); if (this.ruleEditor.isEditable) { for (const span of this._bezierSwatchSpans) { // Adding this swatch to the list of swatches our colorpicker // knows about this.ruleView.tooltips.getTooltip("cubicBezier").addSwatch(span, { onShow: this._onStartEditing, onPreview: this._onSwatchPreview, onCommit: this._onSwatchCommit, onRevert: this._onSwatchRevert, }); const title = l10n("rule.bezierSwatch.tooltip"); span.setAttribute("title", title); } } // Attach the linear easing tooltip to the linear easing swatches this._linearEasingSwatchSpans = this.valueSpan.querySelectorAll( "." + LINEAR_EASING_SWATCH_CLASS ); if (this.ruleEditor.isEditable) { for (const span of this._linearEasingSwatchSpans) { // Adding this swatch to the list of swatches our colorpicker // knows about this.ruleView.tooltips .getTooltip("linearEaseFunction") .addSwatch(span, { onShow: this._onStartEditing, onPreview: this._onSwatchPreview, onCommit: this._onSwatchCommit, onRevert: this._onSwatchRevert, }); span.setAttribute("title", l10n("rule.bezierSwatch.tooltip")); } } // Attach the filter editor tooltip to the filter swatch const span = this.valueSpan.querySelector("." + FILTER_SWATCH_CLASS); if (this.ruleEditor.isEditable) { if (span) { parserOptions.filterSwatch = true; this.ruleView.tooltips.getTooltip("filterEditor").addSwatch( span, { onShow: this._onStartEditing, onPreview: this._onSwatchPreview, onCommit: this._onSwatchCommit, onRevert: this._onSwatchRevert, }, outputParser, parserOptions ); const title = l10n("rule.filterSwatch.tooltip"); span.setAttribute("title", title); } } this.angleSwatchSpans = this.valueSpan.querySelectorAll( "." + ANGLE_SWATCH_CLASS ); if (this.ruleEditor.isEditable) { for (const angleSpan of this.angleSwatchSpans) { angleSpan.on("unit-change", this._onSwatchCommit); const title = l10n("rule.angleSwatch.tooltip"); angleSpan.setAttribute("title", title); } } const nodeFront = this.ruleView.inspector.selection.nodeFront; const flexToggle = this.valueSpan.querySelector(".ruleview-flex"); if (flexToggle) { flexToggle.setAttribute("title", l10n("rule.flexToggle.tooltip")); flexToggle.classList.toggle( "active", this.ruleView.inspector.highlighters.getNodeForActiveHighlighter( this.ruleView.inspector.highlighters.TYPES.FLEXBOX ) === nodeFront ); } const gridToggle = this.valueSpan.querySelector(".ruleview-grid"); if (gridToggle) { gridToggle.setAttribute("title", l10n("rule.gridToggle.tooltip")); gridToggle.classList.toggle( "active", this.ruleView.highlighters.gridHighlighters.has(nodeFront) ); gridToggle.toggleAttribute( "disabled", !this.ruleView.highlighters.canGridHighlighterToggle(nodeFront) ); } const shapeToggle = this.valueSpan.querySelector(".ruleview-shapeswatch"); if (shapeToggle) { const mode = "css" + name .split("-") .map(s => { return s[0].toUpperCase() + s.slice(1); }) .join(""); shapeToggle.setAttribute("data-mode", mode); } // Now that we have updated the property's value, we might have a pending // click on the value container. If we do, we have to trigger a click event // on the right element. // If we are dragging, we don't need to handle the pending click if (this._hasPendingClick && !this._isDragging) { this._hasPendingClick = false; let elToClick; if (this._clickedElementOptions !== null) { const { selector, index } = this._clickedElementOptions; elToClick = this.valueSpan.querySelectorAll(selector)[index]; this._clickedElementOptions = null; } if (!elToClick) { elToClick = this.valueSpan; } elToClick.click(); } // Populate the computed styles and shorthand overridden styles. this._updateComputed(); this._updateShorthandOverridden(); // Update the rule property highlight. this.ruleView._updatePropertyHighlight(this); // Restore focus back to the element whose markup was recreated above. if (focusedElSelector) { const elementToFocus = this.doc.querySelector(focusedElSelector); if (elementToFocus) { elementToFocus.focus(); } } }, _onStartEditing() { this.element.classList.remove("ruleview-overridden"); this.filterProperty.hidden = true; this.enable.style.visibility = "hidden"; this.expander.style.display = "none"; }, get shouldShowComputedExpander() { // Only show the expander to reveal computed properties if: // - the computed properties are actually different from the current property (i.e // these are longhands while the current property is the shorthand) // - all of the computed properties have defined values. In case the current property // value contains CSS variables, then the computed properties will be missing and we // want to avoid showing them. return ( this.prop.computed.some(c => c.name !== this.prop.name) && !this.prop.computed.every(c => !c.value) ); }, /** * Update the visibility of the enable checkbox, the warning indicator, the used * indicator and the filter property, as well as the overridden state of the property. */ updatePropertyState() { if (this.prop.enabled) { this.enable.style.removeProperty("visibility"); } else { this.enable.style.visibility = "visible"; } this.enable.checked = this.prop.enabled; this.warning.title = !this.isNameValid() ? l10n("rule.warningName.title") : l10n("rule.warning.title"); this.warning.hidden = this.editing || this.isValid(); this.filterProperty.hidden = this.editing || !this.isValid() || !this.prop.overridden || this.ruleEditor.rule.isUnmatched; this.expander.style.display = this.shouldShowComputedExpander ? "inline-block" : "none"; if ( !this.editing && (this.prop.overridden || !this.prop.enabled || !this.prop.isKnownProperty) ) { this.element.classList.add("ruleview-overridden"); } else { this.element.classList.remove("ruleview-overridden"); } this.updatePropertyUsedIndicator(); if (inlineCompatibilityWarningEnabled) { this.updatePropertyCompatibilityIndicator(); } }, updatePropertyUsedIndicator() { const { used } = this.prop.isUsed(); if (this.editing || this.prop.overridden || !this.prop.enabled || used) { this.element.classList.remove("unused"); this.unusedState.hidden = true; } else { this.element.classList.add("unused"); this.unusedState.hidden = false; } }, async updatePropertyCompatibilityIndicator() { const { isCompatible } = await this.prop.isCompatible(); if (this.editing || isCompatible) { this.compatibilityState.hidden = true; } else { this.compatibilityState.hidden = false; } }, /** * Update the indicator for computed styles. The computed styles themselves * are populated on demand, when they become visible. */ _updateComputed() { this.computed.innerHTML = ""; this.expander.style.display = !this.editing && this.shouldShowComputedExpander ? "inline-block" : "none"; this._populatedComputed = false; if (this.expander.hasAttribute("open")) { this._populateComputed(); } }, /** * Populate the list of computed styles. */ _populateComputed() { if (this._populatedComputed) { return; } this._populatedComputed = true; for (const computed of this.prop.computed) { // Don't bother to duplicate information already // shown in the text property. if (computed.name === this.prop.name) { continue; } // Store the computed style element for easy access when highlighting // styles computed.element = this._createComputedListItem( this.computed, computed, "ruleview-computed" ); } }, /** * Update the indicator for overridden shorthand styles. The shorthand * overridden styles themselves are populated on demand, when they * become visible. */ _updateShorthandOverridden() { this.shorthandOverridden.innerHTML = ""; this._populatedShorthandOverridden = false; this._populateShorthandOverridden(); }, /** * Populate the list of overridden shorthand styles. */ _populateShorthandOverridden() { if ( this._populatedShorthandOverridden || this.prop.overridden || !this.shouldShowComputedExpander ) { return; } this._populatedShorthandOverridden = true; for (const computed of this.prop.computed) { // Don't display duplicate information or show properties // that are completely overridden. if (computed.name === this.prop.name || !computed.overridden) { continue; } this._createComputedListItem( this.shorthandOverridden, computed, "ruleview-overridden-item" ); } }, /** * Creates and populates a list item with the computed CSS property. */ _createComputedListItem(parentEl, computed, className) { const li = createChild(parentEl, "li", { class: className, }); if (computed.overridden) { li.classList.add("ruleview-overridden"); } const nameContainer = createChild(li, "span", { class: "ruleview-namecontainer", }); createChild(nameContainer, "span", { class: "ruleview-propertyname theme-fg-color3", textContent: computed.name, }); appendText(nameContainer, ": "); const outputParser = this.ruleView._outputParser; const frag = outputParser.parseCssProperty(computed.name, computed.value, { colorSwatchClass: "ruleview-swatch ruleview-colorswatch", urlClass: "theme-link", baseURI: this.sheetHref, fontFamilyClass: "ruleview-font-family", }); // Store the computed property value that was parsed for output computed.parsedValue = frag.textContent; const propertyContainer = createChild(li, "span", { class: "ruleview-propertyvaluecontainer", }); createChild(propertyContainer, "span", { class: "ruleview-propertyvalue theme-fg-color1", child: frag, }); appendText(propertyContainer, ";"); return li; }, /** * Handle updates to the preference which disables/enables the feature to * edit size properties on drag. */ _onDraggablePreferenceChanged() { if (this._isDraggableProperty(this.prop)) { this._addDraggingCapability(); } else { this._removeDraggingCapacity(); } }, /** * Stop clicks propogating down the tree from the enable / disable checkbox. */ _onEnableClicked(event) { event.stopPropagation(); }, /** * Handles clicks on the disabled property. */ _onEnableChanged(event) { this.prop.setEnabled(this.enable.checked); event.stopPropagation(); this.telemetry.recordEvent("edit_rule", "ruleview"); }, /** * Handles clicks on the computed property expander. If the computed list is * open due to user expanding or style filtering, collapse the computed list * and close the expander. Otherwise, add user-open attribute which is used to * expand the computed list and tracks whether or not the computed list is * expanded by manually by the user. */ _onExpandClicked(event) { if ( this.computed.hasAttribute("filter-open") || this.computed.hasAttribute("user-open") ) { this.expander.removeAttribute("open"); this.computed.removeAttribute("filter-open"); this.computed.removeAttribute("user-open"); this.shorthandOverridden.hidden = false; this._populateShorthandOverridden(); } else { this.expander.setAttribute("open", "true"); this.computed.setAttribute("user-open", ""); this.shorthandOverridden.hidden = true; this._populateComputed(); } event.stopPropagation(); }, /** * Expands the computed list when a computed property is matched by the style * filtering. The filter-open attribute is used to track whether or not the * computed list was toggled opened by the filter. */ expandForFilter() { if (!this.computed.hasAttribute("user-open")) { this.expander.setAttribute("open", "true"); this.computed.setAttribute("filter-open", ""); this._populateComputed(); } }, /** * Collapses the computed list that was expanded by style filtering. */ collapseForFilter() { this.computed.removeAttribute("filter-open"); if (!this.computed.hasAttribute("user-open")) { this.expander.removeAttribute("open"); } }, /** * Called when the property name's inplace editor is closed. * Ignores the change if the user pressed escape, otherwise * commits it. * * @param {String} value * The value contained in the editor. * @param {Boolean} commit * True if the change should be applied. * @param {Number} direction * The move focus direction number. */ _onNameDone(value, commit, direction) { const isNameUnchanged = (!commit && !this.ruleEditor.isEditing) || this.committed.name === value; if (this.prop.value && isNameUnchanged) { return; } this.telemetry.recordEvent("edit_rule", "ruleview"); // Remove a property if the name is empty if (!value.trim()) { this.remove(direction); return; } // Remove a property if the property value is empty and the property // value is not about to be focused if (!this.prop.value && direction !== Services.focus.MOVEFOCUS_FORWARD) { this.remove(direction); return; } // Adding multiple rules inside of name field overwrites the current // property with the first, then adds any more onto the property list. const properties = parseDeclarations(this.cssProperties.isKnown, value); if (properties.length) { this.prop.setName(properties[0].name); this.committed.name = this.prop.name; if (!this.prop.enabled) { this.prop.setEnabled(true); } if (properties.length > 1) { this.prop.setValue(properties[0].value, properties[0].priority); this.ruleEditor.addProperties(properties.slice(1), this.prop); } } }, /** * Remove property from style and the editors from DOM. * Begin editing next or previous available property given the focus * direction. * * @param {Number} direction * The move focus direction number. */ remove(direction) { if (this._colorSwatchSpans && this._colorSwatchSpans.length) { for (const span of this._colorSwatchSpans) { this.ruleView.tooltips.getTooltip("colorPicker").removeSwatch(span); span.off("unit-change", this._onSwatchCommit); } } if (this.angleSwatchSpans && this.angleSwatchSpans.length) { for (const span of this.angleSwatchSpans) { span.off("unit-change", this._onSwatchCommit); } } this.ruleView.off( "draggable-preference-updated", this._onDraggablePreferenceChanged ); this.element.remove(); this.ruleEditor.rule.editClosestTextProperty(this.prop, direction); this.nameSpan.textProperty = null; this.valueSpan.textProperty = null; this.prop.remove(); }, /** * Called when a value editor closes. If the user pressed escape, * revert to the value this property had before editing. * * @param {String} value * The value contained in the editor. * @param {Boolean} commit * True if the change should be applied. * @param {Number} direction * The move focus direction number. */ _onValueDone(value = "", commit, direction) { const parsedProperties = this._getValueAndExtraProperties(value); const val = parseSingleValue( this.cssProperties.isKnown, parsedProperties.firstValue ); const isValueUnchanged = (!commit && !this.ruleEditor.isEditing) || (!parsedProperties.propertiesToAdd.length && this.committed.value === val.value && this.committed.priority === val.priority); // If the value is not empty and unchanged, revert the property back to // its original value and enabled or disabled state if (value.trim() && isValueUnchanged) { this.ruleEditor.rule.previewPropertyValue( this.prop, val.value, val.priority ); this.rule.setPropertyEnabled(this.prop, this.prop.enabled); return; } // Check if unit of value changed to add dragging feature if (this._isDraggableProperty(val)) { this._addDraggingCapability(); } else { this._removeDraggingCapacity(); } this.telemetry.recordEvent("edit_rule", "ruleview"); // First, set this property value (common case, only modified a property) this.prop.setValue(val.value, val.priority); if (!this.prop.enabled) { this.prop.setEnabled(true); } this.committed.value = this.prop.value; this.committed.priority = this.prop.priority; // If needed, add any new properties after this.prop. this.ruleEditor.addProperties(parsedProperties.propertiesToAdd, this.prop); // If the input value is empty and the focus is moving forward to the next // editable field, then remove the whole property. // A timeout is used here to accurately check the state, since the inplace // editor `done` and `destroy` events fire before the next editor // is focused. if (!value.trim() && direction !== Services.focus.MOVEFOCUS_BACKWARD) { setTimeout(() => { if (!this.editing) { this.remove(direction); } }, 0); } }, /** * Called when the swatch editor wants to commit a value change. */ _onSwatchCommit() { this._onValueDone(this.valueSpan.textContent, true); this.update(); }, /** * Called when the swatch editor wants to preview a value change. */ _onSwatchPreview() { this._previewValue(this.valueSpan.textContent); }, /** * Called when the swatch editor closes from an ESC. Revert to the original * value of this property before editing. */ _onSwatchRevert() { this._previewValue(this.prop.value, true); this.update(); }, /** * Parse a value string and break it into pieces, starting with the * first value, and into an array of additional properties (if any). * * Example: Calling with "red; width: 100px" would return * { firstValue: "red", propertiesToAdd: [{ name: "width", value: "100px" }] } * * @param {String} value * The string to parse * @return {Object} An object with the following properties: * firstValue: A string containing a simple value, like * "red" or "100px!important" * propertiesToAdd: An array with additional properties, following the * parseDeclarations format of {name,value,priority} */ _getValueAndExtraProperties(value) { // The inplace editor will prevent manual typing of multiple properties, // but we need to deal with the case during a paste event. // Adding multiple properties inside of value editor sets value with the // first, then adds any more onto the property list (below this property). let firstValue = value; let propertiesToAdd = []; const properties = parseDeclarations(this.cssProperties.isKnown, value); // Check to see if the input string can be parsed as multiple properties if (properties.length) { // Get the first property value (if any), and any remaining // properties (if any) if (!properties[0].name && properties[0].value) { firstValue = properties[0].value; propertiesToAdd = properties.slice(1); } else if (properties[0].name && properties[0].value) { // In some cases, the value could be a property:value pair // itself. Join them as one value string and append // potentially following properties firstValue = properties[0].name + ": " + properties[0].value; propertiesToAdd = properties.slice(1); } } return { propertiesToAdd, firstValue, }; }, /** * Live preview this property, without committing changes. * * @param {String} value * The value to set the current property to. * @param {Boolean} reverting * True if we're reverting the previously previewed value */ _previewValue(value, reverting = false) { // Since function call is debounced, we need to make sure we are still // editing, and any selector modifications have been completed if (!reverting && (!this.editing || this.ruleEditor.isEditing)) { return; } const val = parseSingleValue(this.cssProperties.isKnown, value); this.ruleEditor.rule.previewPropertyValue( this.prop, val.value, val.priority ); }, /** * Check if the event passed has a "small increment" modifier * Alt on macosx and ctrl on other OSs * * @param {KeyboardEvent} event * @returns {Boolean} */ _hasSmallIncrementModifier(event) { const modifier = lazy.AppConstants.platform === "macosx" ? "altKey" : "ctrlKey"; return event[modifier] === true; }, /** * Parses the value to check if it is a dimension * e.g. if the input is "128px" it will return an object like * { groups: { value: "128", unit: "px"}} * * @param {String} value * @returns {Object|null} */ _parseDimension(value) { // The regex handles values like +1, -1, 1e4, .4, 1.3e-4, 1.567 const cssDimensionRegex = /^(?[+-]?(\d*\.)?\d+(e[+-]?\d+)?)(?(%|[a-zA-Z]+))$/; return value.match(cssDimensionRegex); }, /** * Check if a textProperty value is supported to add the dragging feature * * @param {TextProperty} textProperty * @returns {Boolean} */ _isDraggableProperty(textProperty) { // Check if the feature is explicitly disabled. if (!this.ruleView.draggablePropertiesEnabled) { return false; } // temporary way of fixing the bug when editing inline styles // otherwise the textPropertyEditor object is destroyed on each value edit // See Bug 1755024 if (this.rule.domRule.type == ELEMENT_STYLE) { return false; } const nbValues = textProperty.value.split(" ").length; if (nbValues > 1) { // we do not support values like "1px solid red" yet // See 1755025 return false; } const dimensionMatchObj = this._parseDimension(textProperty.value); return !!dimensionMatchObj; }, _draggingOnMouseDown(event) { this._isDragging = true; this.valueSpan.setPointerCapture(event.pointerId); this._draggingController = new AbortController(); const { signal } = this._draggingController; // turn off user-select in CSS when we drag this.valueSpan.classList.add(IS_DRAGGING_CLASSNAME); const dimensionObj = this._parseDimension(this.prop.value); const { value, unit } = dimensionObj.groups; this._draggingValueCache = { isInDeadzone: true, previousScreenX: event.screenX, value: parseFloat(value), unit, }; this.valueSpan.addEventListener("mousemove", this._draggingOnMouseMove, { signal, }); this.valueSpan.addEventListener("mouseup", this._draggingOnMouseUp, { signal, }); this.valueSpan.addEventListener("keydown", this._draggingOnKeydown, { signal, }); }, _draggingOnMouseMove(event) { if (!this._isDragging) { return; } const { isInDeadzone, previousScreenX } = this._draggingValueCache; let deltaX = event.screenX - previousScreenX; // If `isInDeadzone` is still true, the user has not previously left the deadzone. if (isInDeadzone) { // If the mouse is still in the deadzone, bail out immediately. if (Math.abs(deltaX) < DRAGGING_DEADZONE_DISTANCE) { return; } // Otherwise, remove the DRAGGING_DEADZONE_DISTANCE from the current deltaX, so that // the value does not update too abruptly. deltaX = Math.sign(deltaX) * (Math.abs(deltaX) - DRAGGING_DEADZONE_DISTANCE); // Update the state to remember the user is out of the deadzone. this._draggingValueCache.isInDeadzone = false; } let draggingSpeed = DEFAULT_DRAGGING_SPEED; if (event.shiftKey) { draggingSpeed = FAST_DRAGGING_SPEED; } else if (this._hasSmallIncrementModifier(event)) { draggingSpeed = SLOW_DRAGGING_SPEED; } const delta = deltaX * draggingSpeed; this._draggingValueCache.previousScreenX = event.screenX; this._draggingValueCache.value += delta; if (delta == 0) { return; } const { value, unit } = this._draggingValueCache; // We use toFixed to avoid the case where value is too long, 9.00001px for example const roundedValue = Number.isInteger(value) ? value : value.toFixed(1); this.prop.setValue(roundedValue + unit, this.prop.priority); this.ruleView.emitForTests("property-updated-by-dragging"); this._hasDragged = true; }, _draggingOnMouseUp(event) { if (!this._isDragging) { return; } if (this._hasDragged) { this.committed.value = this.prop.value; this.prop.setEnabled(true); } this._onStopDragging(event); }, _draggingOnKeydown(event) { if (event.key == "Escape") { this.prop.setValue(this.committed.value, this.committed.priority); this._onStopDragging(event); event.preventDefault(); } }, _onStopDragging(event) { // childHasDragged is used to stop the propagation of a click event when we // release the mouse in the ruleview. // The click event is not emitted when we have a pending click on the text property. if (this._hasDragged && !this._hasPendingClick) { this.ruleView.childHasDragged = true; } this._isDragging = false; this._hasDragged = false; this._draggingValueCache = null; this.valueSpan.releasePointerCapture(event.pointerId); this.valueSpan.classList.remove(IS_DRAGGING_CLASSNAME); this._draggingController.abort(); }, /** * add event listeners to add the ability to modify any size value * by dragging the mouse horizontally */ _addDraggingCapability() { if (this.valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME)) { return; } this.valueSpan.classList.add(DRAGGABLE_VALUE_CLASSNAME); this.valueSpan.addEventListener("mousedown", this._draggingOnMouseDown); }, _removeDraggingCapacity() { if (!this.valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME)) { return; } this._draggingController = null; this.valueSpan.classList.remove(DRAGGABLE_VALUE_CLASSNAME); this.valueSpan.removeEventListener("mousedown", this._draggingOnMouseDown); }, /** * Validate this property. Does it make sense for this value to be assigned * to this property name? This does not apply the property value * * @return {Boolean} true if the property name + value pair is valid, false otherwise. */ isValid() { return this.prop.isValid(); }, /** * Validate the name of this property. * @return {Boolean} true if the property name is valid, false otherwise. */ isNameValid() { return this.prop.isNameValid(); }, /** * Display grid-template-area value strings each on their own line * to display it in an ascii-art style matrix */ _formatGridTemplateAreasValue() { this.valueSpan.classList.add("ruleview-propertyvalue-break-spaces"); let quoteSymbolsUsed = []; const getQuoteSymbolsUsed = cssValue => { const regex = /\"|\'/g; const found = cssValue.match(regex); quoteSymbolsUsed = found.filter((_, i) => i % 2 === 0); }; getQuoteSymbolsUsed(this.valueSpan.innerText); this.valueSpan.innerText = this.valueSpan.innerText .split('"') .filter(s => s !== "") .map(s => s.split("'")) .flat() .map(s => s.trim().replace(/\s+/g, " ")) .filter(s => s.length) .map(line => line.split(" ")) .map((line, i, lines) => line.map((col, j) => col.padEnd(Math.max(...lines.map(l => l[j].length)), " ") ) ) .map( (line, i) => `\n${quoteSymbolsUsed[i]}` + line.join(" ") + quoteSymbolsUsed[i] ) .join(" "); }, }; module.exports = TextPropertyEditor;