/* 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 { PSEUDO_CLASSES, ELEMENT_SPECIFIC_PSEUDO_CLASSES, } = require("resource://devtools/shared/css/constants.js"); const { style: { ELEMENT_STYLE, PRES_HINTS }, } = require("resource://devtools/shared/constants.js"); const Rule = require("resource://devtools/client/inspector/rules/models/rule.js"); const { InplaceEditor, editableField, editableItem, } = require("resource://devtools/client/shared/inplace-editor.js"); const TextPropertyEditor = require("resource://devtools/client/inspector/rules/views/text-property-editor.js"); const { createChild, blurOnMultipleProperties, promiseWarn, } = require("resource://devtools/client/inspector/shared/utils.js"); const { parseNamedDeclarations, parsePseudoClassesAndAttributes, SELECTOR_ATTRIBUTE, SELECTOR_ELEMENT, SELECTOR_PSEUDO_CLASS, } = require("resource://devtools/shared/css/parsing-utils.js"); const EventEmitter = require("resource://devtools/shared/event-emitter.js"); const CssLogic = require("resource://devtools/shared/inspector/css-logic.js"); loader.lazyRequireGetter( this, "Tools", "resource://devtools/client/definitions.js", true ); loader.lazyRequireGetter( this, "PluralForm", "resource://devtools/shared/plural-form.js", true ); const STYLE_INSPECTOR_PROPERTIES = "devtools/shared/locales/styleinspector.properties"; const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES); const COMPONENT_PROPERTIES = "devtools/client/locales/components.properties"; const COMPONENT_L10N = new LocalizationHelper(COMPONENT_PROPERTIES); loader.lazyGetter(this, "NEW_PROPERTY_NAME_INPUT_LABEL", function () { return STYLE_INSPECTOR_L10N.getStr("rule.newPropertyName.label"); }); const UNUSED_CSS_PROPERTIES_HIDE_THRESHOLD = 10; const INDENT_SIZE = 2; const INDENT_STR = " ".repeat(INDENT_SIZE); /** * RuleEditor is responsible for the following: * Owns a Rule object and creates a list of TextPropertyEditors * for its TextProperties. * Manages creation of new text properties. */ class RuleEditor extends EventEmitter { /** * @param {CssRuleView} ruleView * The CssRuleView containg the document holding this rule editor. * @param {Rule} rule * The Rule object we're editing. * @param {object} options * @param {Set} options.elementsWithPendingClicks * @param {Function} options.onShowUnusedCustomCssProperties * @param {boolean} options.shouldHideUnusedCustomCssProperties */ constructor(ruleView, rule, options = {}) { super(); this.ruleView = ruleView; this.doc = this.ruleView.styleDocument; this.toolbox = this.ruleView.inspector.toolbox; this.telemetry = this.toolbox.telemetry; this.rule = rule; this.options = options; this.isEditable = rule.isEditable(); // Flag that blocks updates of the selector and properties when it is // being edited this.isEditing = false; this.#abortController = new this.doc.defaultView.AbortController(); const { signal } = this.#abortController; const baseEventConfig = { signal }; this.rule.domRule.on( "location-changed", this.#locationChanged, baseEventConfig ); this.toolbox.on("tool-registered", this.#onToolChanged, baseEventConfig); this.toolbox.on("tool-unregistered", this.#onToolChanged, baseEventConfig); this.#create(); } #abortController; #unusedCssVariableDeclarations; #showUnusedCustomCssPropertiesButton; #unsubscribeSourceMap; #ruleViewIsEditing; destroy() { for (const prop of this.rule.textProps) { prop.editor?.destroy(); } this.#unusedCssVariableDeclarations = null; if (this.#showUnusedCustomCssPropertiesButton) { this.#nullifyShowUnusedCustomCssProperties({ removeFromDom: false }); } this.#abortController.abort(); if (this.#unsubscribeSourceMap) { this.#unsubscribeSourceMap(); } // Remove from the DOM so that CssRuleView can clear empty rule containers this.element.remove(); this.element = null; } #sourceMapURLService; get sourceMapURLService() { if (!this.#sourceMapURLService) { // sourceMapURLService is a lazy getter in the toolbox. this.#sourceMapURLService = this.toolbox.sourceMapURLService; } return this.#sourceMapURLService; } get isSelectorEditable() { return ( this.isEditable && this.rule.domRule.type !== ELEMENT_STYLE && this.rule.domRule.type !== CSSRule.KEYFRAME_RULE && this.rule.domRule.className !== "CSSPositionTryRule" ); } get showSelectorHighlighterButton() { return ( this.rule.domRule.type !== CSSRule.KEYFRAME_RULE && this.rule.domRule.className !== "CSSPositionTryRule" ); } #create() { this.element = this.doc.createElement("div"); this.element.className = "ruleview-rule devtools-monospace" + (this.rule.inherited ? " ruleview-rule-inherited" : ""); this.element.dataset.ruleId = this.rule.domRule.actorID; this.element.setAttribute("uneditable", !this.isEditable); this.element.setAttribute("unmatched", this.rule.isUnmatched); this.element.setAttribute("role", "article"); // This is used by tests this.element._ruleEditor = this; // Give a relative position for the inplace editor's measurement // span to be placed absolutely against. this.element.style.position = "relative"; // Add the source link for supported rules. inline style and pres hints are not visible // in the StyleEditor, so don't show anything for such rule. if ( this.rule.domRule.type !== ELEMENT_STYLE && this.rule.domRule.type !== PRES_HINTS ) { this.source = createChild(this.element, "div", { class: "ruleview-rule-source theme-link", }); this.source.addEventListener("click", this.#onSourceClick); const sourceLabel = this.doc.createElement("a"); sourceLabel.classList.add("ruleview-rule-source-label"); this.source.appendChild(sourceLabel); } this.#updateSourceLink(); if (this.rule.domRule.ancestorData.length) { const ancestorsFrag = this.doc.createDocumentFragment(); this.rule.domRule.ancestorData.forEach((ancestorData, index) => { const ancestorItem = this.doc.createElement("div"); ancestorItem.setAttribute("role", "listitem"); ancestorsFrag.append(ancestorItem); ancestorItem.setAttribute("data-ancestor-index", index); ancestorItem.classList.add("ruleview-rule-ancestor"); if (ancestorData.type) { ancestorItem.classList.add(ancestorData.type); } // Indent each parent selector if (index) { createChild(ancestorItem, "span", { class: "ruleview-rule-indent", textContent: INDENT_STR.repeat(index), }); } const selectorContainer = createChild(ancestorItem, "span", { class: "ruleview-rule-ancestor-selectorcontainer", }); if (ancestorData.type == "container") { ancestorItem.classList.add("container-query", "has-tooltip"); createChild(selectorContainer, "span", { class: "container-query-declaration", textContent: `@container${ancestorData.containerName ? " " + ancestorData.containerName : ""}`, }); const jumpToNodeButton = createChild(selectorContainer, "button", { class: "open-inspector", title: l10n("rule.containerQuery.selectContainerButton.tooltip"), }); let containerNodeFront; const getNodeFront = async () => { if (!containerNodeFront) { const res = await this.rule.domRule.getQueryContainerForNode( index, this.rule.inherited || this.ruleView.inspector.selection.nodeFront ); containerNodeFront = res.node; } return containerNodeFront; }; jumpToNodeButton.addEventListener("click", async () => { const front = await getNodeFront(); if (!front) { return; } this.ruleView.inspector.selection.setNodeFront(front); await this.ruleView.inspector.highlighters.hideHighlighterType( this.ruleView.inspector.highlighters.TYPES.BOXMODEL ); }); ancestorItem.addEventListener("mouseenter", async () => { const front = await getNodeFront(); if (!front) { return; } await this.ruleView.inspector.highlighters.showHighlighterTypeForNode( this.ruleView.inspector.highlighters.TYPES.BOXMODEL, front ); }); ancestorItem.addEventListener("mouseleave", async () => { await this.ruleView.inspector.highlighters.hideHighlighterType( this.ruleView.inspector.highlighters.TYPES.BOXMODEL ); }); createChild(selectorContainer, "span", { // Add a space between the container name (or @container if there's no name) // and the query so the title, which is computed from the DOM, displays correctly. textContent: " " + ancestorData.containerQuery, }); } else if (ancestorData.type == "layer") { selectorContainer.append( this.doc.createTextNode( `@layer${ancestorData.value ? " " + ancestorData.value : ""}` ) ); } else if (ancestorData.type == "media") { selectorContainer.append( this.doc.createTextNode(`@media ${ancestorData.value}`) ); } else if (ancestorData.type == "supports") { selectorContainer.append( this.doc.createTextNode(`@supports ${ancestorData.conditionText}`) ); } else if (ancestorData.type == "import") { selectorContainer.append( this.doc.createTextNode(`@import ${ancestorData.value}`) ); } else if (ancestorData.type == "scope") { let text = `@scope`; if (ancestorData.start) { text += ` (${ancestorData.start})`; if (ancestorData.end) { text += ` to (${ancestorData.end})`; } } selectorContainer.append(this.doc.createTextNode(text)); } else if (ancestorData.type == "starting-style") { selectorContainer.append(this.doc.createTextNode(`@starting-style`)); } else if (ancestorData.type == "appearance-base") { selectorContainer.append(this.doc.createTextNode(`@appearance-base`)); } else if (ancestorData.selectors) { ancestorData.selectors.forEach((selector, i) => { if (i !== 0) { createChild(selectorContainer, "span", { class: "ruleview-selector-separator", textContent: ", ", }); } const selectorEl = createChild(selectorContainer, "span", { class: "ruleview-selector", textContent: selector, }); const warningsContainer = this.#createWarningsElementForSelector( i, ancestorData.selectorWarnings ); if (warningsContainer) { selectorEl.append(warningsContainer); } }); } else { // We shouldn't get here as `type` should only match to what can be set in // the StyleRuleActor form, but just in case, let's return an empty string. console.warn("Unknown ancestor data type:", ancestorData.type); return; } createChild(ancestorItem, "span", { class: "ruleview-ancestor-ruleopen", textContent: " {", }); }); // We can't use a proper "ol" as it will mess with selection copy text, // adding spaces on list item instead of the one we craft (.ruleview-rule-indent) this.ancestorDataEl = createChild(this.element, "div", { class: "ruleview-rule-ancestor-data", role: "list", }); this.ancestorDataEl.append(ancestorsFrag); } this.ruleviewCodeEl = createChild(this.element, "div", { class: "ruleview-code", }); const header = createChild(this.ruleviewCodeEl, "div", {}); createChild(header, "span", { class: "ruleview-rule-indent", textContent: INDENT_STR.repeat(this.rule.domRule.ancestorData.length), }); this.selectorText = createChild(header, "span", { class: "ruleview-selectors-container", tabindex: this.isSelectorEditable ? "0" : "-1", }); if ( this.rule.domRule.type === ELEMENT_STYLE || this.rule.domRule.type === PRES_HINTS ) { this.selectorText.classList.add("alternative-selector"); } if (this.isSelectorEditable) { this.selectorText.addEventListener("click", event => { // Clicks within the selector shouldn't propagate any further. event.stopPropagation(); }); editableField({ element: this.selectorText, done: this.#onSelectorDone, cssProperties: this.rule.cssProperties, // (Shift+)Tab will move the focus to the previous/next editable field (so property name, // or new property of the previous rule). focusEditableFieldAfterApply: true, focusEditableFieldContainerSelector: ".ruleview-rule", // We don't want Enter to trigger the next editable field, just to validate // what the user entered, close the editor, and focus the span so the user can // navigate with the keyboard as expected, unless the user has // devtools.inspector.rule-view.focusNextOnEnter set to true stopOnReturn: this.ruleView.inplaceEditorFocusNextOnEnter !== true, }); } else { this.selectorText.classList.add("uneditable-selector"); } if (this.rule.domRule.type !== CSSRule.KEYFRAME_RULE) { // This is a "normal" rule with a selector. let computedSelector = ""; if (this.rule.domRule.selectors) { computedSelector = this.rule.domRule.computedSelector; // Otherwise, the rule is either inherited or inline, and selectors will // be computed on demand when the highlighter is requested. } if (this.showSelectorHighlighterButton) { const isHighlighted = this.ruleView.isSelectorHighlighted(computedSelector); // Handling of click events is delegated to CssRuleView.handleEvent() createChild(header, "button", { class: "ruleview-selectorhighlighter js-toggle-selector-highlighter" + (isHighlighted ? " highlighted" : ""), "aria-pressed": isHighlighted, // This is used in rules.js for the selector highlighter "data-computed-selector": computedSelector, title: l10n("rule.selectorHighlighter.tooltip"), }); } } this.openBrace = createChild(header, "span", { class: "ruleview-ruleopen", textContent: " {", }); // We can't use a proper "ol" as it will mess with selection copy text, // adding spaces on list item instead of the one we craft (.ruleview-rule-indent) this.propertyList = createChild(this.ruleviewCodeEl, "div", { class: "ruleview-propertylist", role: "list", }); this.populate(); this.closeBrace = createChild(this.ruleviewCodeEl, "div", { class: "ruleview-ruleclose", tabindex: this.isEditable ? "0" : "-1", }); if (this.rule.domRule.ancestorData.length) { createChild(this.closeBrace, "span", { class: "ruleview-rule-indent", textContent: INDENT_STR.repeat(this.rule.domRule.ancestorData.length), }); } this.closeBrace.append(this.doc.createTextNode("}")); if (this.rule.domRule.ancestorData.length) { let closingBracketsText = ""; for (let i = this.rule.domRule.ancestorData.length - 1; i >= 0; i--) { if (i) { closingBracketsText += INDENT_STR.repeat(i); } closingBracketsText += "}\n"; } createChild(this.ruleviewCodeEl, "div", { class: "ruleview-ancestor-ruleclose", textContent: closingBracketsText, }); } if (this.isEditable) { // A newProperty editor should only be created when no editor was // previously displayed. Since the editors are cleared on blur, // check this.ruleview.isEditing on mousedown this.#ruleViewIsEditing = false; this.ruleviewCodeEl.addEventListener("mousedown", () => { this.#ruleViewIsEditing = this.ruleView.isEditing; }); this.ruleviewCodeEl.addEventListener("click", () => { const selection = this.doc.defaultView.getSelection(); if (selection.isCollapsed && !this.#ruleViewIsEditing) { this.newProperty(); } // Cleanup the #ruleViewIsEditing flag this.#ruleViewIsEditing = false; }); this.element.addEventListener("mousedown", () => { this.doc.defaultView.focus(); }); // Create a property editor when the close brace is clicked. editableItem({ element: this.closeBrace }, () => { this.newProperty(); }); } } /** * Returns the selector warnings element, or null if selector at selectorIndex * does not have any warning. * * @param {Integer} selectorIndex: The index of the selector we want to create the * warnings for * @param {Array} selectorWarnings: An array of object of the following shape: * - {Integer} index: The index of the selector this applies to * - {String} kind: Identifies the warning * @returns {Element|null} */ #createWarningsElementForSelector(selectorIndex, selectorWarnings) { if (!selectorWarnings) { return null; } const warningKinds = []; for (const { index, kind } of selectorWarnings) { if (index !== selectorIndex) { continue; } warningKinds.push(kind); } if (!warningKinds.length) { return null; } const warningsContainer = this.doc.createElement("div"); warningsContainer.classList.add( "ruleview-selector-warnings", "has-tooltip" ); warningsContainer.setAttribute( "data-selector-warning-kind", warningKinds.join(",") ); if (warningKinds.includes("UnconstrainedHas")) { warningsContainer.classList.add("slow"); } return warningsContainer; } /** * Called when a tool is registered or unregistered. */ #onToolChanged = () => { if (!this.source) { return; } // When the source editor is registered, update the source links // to be clickable; and if it is unregistered, update the links to // be unclickable. if (this.toolbox.isToolRegistered("styleeditor")) { this.source.removeAttribute("unselectable"); } else { this.source.setAttribute("unselectable", "true"); } }; /** * Event handler called when a property changes on the * StyleRuleActor. */ #locationChanged = () => { this.#updateSourceLink(); }; #onSourceClick = e => { e.preventDefault(); if (this.source.hasAttribute("unselectable")) { return; } const { inspector } = this.ruleView; if (Tools.styleEditor.isToolSupported(inspector.toolbox)) { inspector.toolbox.viewSourceInStyleEditorByResource( this.rule.sheet, this.rule.ruleLine, this.rule.ruleColumn ); } }; /** * Update the text of the source link to reflect whether we're showing * original sources or not. This is a callback for * SourceMapURLService.subscribeByID, which see. * * @param {object | null} originalLocation * The original position object (url/line/column) or null. */ #updateLocation = originalLocation => { let displayURL = this.rule.sheet?.href; const constructed = this.rule.sheet?.constructed; let line = this.rule.ruleLine; if (originalLocation) { displayURL = originalLocation.url; line = originalLocation.line; } let sourceTextContent = CssLogic.shortSource({ constructed, href: displayURL, }); let displayLocation = displayURL ? displayURL : sourceTextContent; if (line > 0) { sourceTextContent += ":" + line; displayLocation += ":" + line; } const title = COMPONENT_L10N.getFormatStr( "frame.viewsourceinstyleeditor", displayLocation ); const sourceLabel = this.element.querySelector( ".ruleview-rule-source-label" ); sourceLabel.setAttribute("title", title); sourceLabel.setAttribute("href", displayURL); sourceLabel.textContent = sourceTextContent; }; #updateSourceLink() { if (this.source) { if (this.rule.isSystem) { const sourceLabel = this.element.querySelector( ".ruleview-rule-source-label" ); const uaLabel = STYLE_INSPECTOR_L10N.getStr("rule.userAgentStyles"); sourceLabel.textContent = uaLabel + " " + this.rule.title; sourceLabel.setAttribute("href", this.rule.sheet?.href); } else { this.#updateLocation(null); } if (this.rule.sheet && !this.rule.isSystem) { // Only get the original source link if the rule isn't a system // rule and if it isn't an inline rule. if (this.#unsubscribeSourceMap) { this.#unsubscribeSourceMap(); } this.#unsubscribeSourceMap = this.sourceMapURLService.subscribeByID( this.rule.sheet.resourceId, this.rule.ruleLine, this.rule.ruleColumn, this.#updateLocation ); } // Set "unselectable" appropriately. this.#onToolChanged(); } Promise.resolve().then(() => { this.emit("source-link-updated"); }); } /** * Update the rule editor with the contents of the rule. * * @param {boolean} reset * True to completely reset the rule editor before populating. */ populate(reset) { // Clear out existing viewers. this.selectorText.replaceChildren(); // If selector text comes from a css rule, highlight selectors that // actually match. For custom selector text (such as for the 'element' // style, just show the text directly. if ( this.rule.domRule.type === ELEMENT_STYLE || this.rule.domRule.type === PRES_HINTS ) { this.selectorText.textContent = this.rule.selectorText; } else if (this.rule.domRule.type === CSSRule.KEYFRAME_RULE) { this.selectorText.textContent = this.rule.domRule.keyText; } else if (this.rule.domRule.className === "CSSPositionTryRule") { this.selectorText.textContent = this.rule.domRule.name; } else { this.rule.domRule.selectors.forEach((selector, i) => { this.#populateSelector(selector, i); }); } let focusedElSelector; if (reset) { // If we're going to reset the rule (i.e. if this is the `element` rule), // we want to restore the focus after the rule is populated. // So if this element contains the active element, retrieve its selector for later use. if (this.element.contains(this.doc.activeElement)) { focusedElSelector = CssLogic.findCssSelector(this.doc.activeElement); } this.propertyList.replaceChildren(); } this.#unusedCssVariableDeclarations = this.#getUnusedCssVariableDeclarations(); const hideUnusedCssVariableDeclarations = this.#unusedCssVariableDeclarations.size >= // If the button was already displayed, hide unused variables if we have at least // one, even if it's less than the threshold (this.#showUnusedCustomCssPropertiesButton ? 1 : UNUSED_CSS_PROPERTIES_HIDE_THRESHOLD); // If we won't hide any variable, clear the Set of unused variables as it's used in // updateUnusedCssVariables and we might do unnecessary computation if we still // track variables which are actually visible. if (!hideUnusedCssVariableDeclarations) { this.#unusedCssVariableDeclarations.clear(); } for (const prop of this.rule.textProps) { if (hideUnusedCssVariableDeclarations && prop.isUnusedVariable) { continue; } if (!prop.editor && !prop.invisible) { const editor = new TextPropertyEditor(this, prop, { elementsWithPendingClicks: this.options.elementsWithPendingClicks, }); this.propertyList.appendChild(editor.element); } else if (prop.editor) { // If an editor already existed, append it to the bottom now to make sure the // order of editors in the DOM follow the order of the rule's properties. this.propertyList.appendChild(prop.editor.element); } } if (hideUnusedCssVariableDeclarations) { if (!this.#showUnusedCustomCssPropertiesButton) { this.#showUnusedCustomCssPropertiesButton = this.doc.createElement("button"); this.#showUnusedCustomCssPropertiesButton.classList.add( "devtools-button", "devtools-button-standalone", "ruleview-show-unused-custom-css-properties" ); this.#showUnusedCustomCssPropertiesButton.addEventListener( "click", this.#onShowUnusedCustomCssPropertiesButtonClick ); } this.ruleviewCodeEl.insertBefore( this.#showUnusedCustomCssPropertiesButton, this.closeBrace ); this.#updateShowUnusedCustomCssPropertiesButtonText(); } else if (this.#showUnusedCustomCssPropertiesButton) { this.#nullifyShowUnusedCustomCssProperties(); } // Set focus if the focus is still in the current document (avoid stealing // the focus, see Bug 1911627). if (this.doc.hasFocus() && focusedElSelector) { const elementToFocus = this.doc.querySelector(focusedElSelector); if (elementToFocus && this.element.contains(elementToFocus)) { // We need to wait for a tick for the focus to be properly set setTimeout(() => { elementToFocus.focus(); this.ruleView.emitForTests("rule-editor-focus-reset"); }, 0); } } } updateUnusedCssVariables() { if ( !this.#unusedCssVariableDeclarations || !this.#unusedCssVariableDeclarations.size ) { return; } // Store the list of what used to be unused const previouslyUnused = Array.from(this.#unusedCssVariableDeclarations); // Then compute the list of unused variables again this.#unusedCssVariableDeclarations = this.#getUnusedCssVariableDeclarations(); for (const prop of previouslyUnused) { if (this.#unusedCssVariableDeclarations.has(prop)) { continue; } // The prop wasn't used, but now is, so let's show it this.showUnusedCssVariable(prop, { updateButton: false, }); } this.#updateShowUnusedCustomCssPropertiesButtonText(); } /** * Create a TextPropertyEditor for TextProperty representing an unused CSS variable. * * @param {TextProperty} prop * @param {object} options * @param {boolean} options.updateButton * @returns {TextPropertyEditor|null} Returns null if passed TextProperty isn't found * in the list of unused css variables */ showUnusedCssVariable(prop, { updateButton = true } = {}) { if (prop.editor) { return null; } this.#unusedCssVariableDeclarations.delete(prop); const editor = new TextPropertyEditor(this, prop, { elementsWithPendingClicks: this.options.elementsWithPendingClicks, }); const declarationIndex = this.rule.textProps.indexOf(prop); // We need to insert the editor according to its index in the list of declarations. // So let's try to find the prop which is placed higher and is visible let nextSibling; for (let i = declarationIndex + 1; i < this.rule.textProps.length; i++) { const currentProp = this.rule.textProps[i]; if (currentProp.editor) { nextSibling = currentProp.editor.element; break; } } // If we couldn't find nextSibling, that means that no declaration with higher index // is visible, so we can put the newly visible property at the end this.propertyList.insertBefore(editor.element, nextSibling || null); if (updateButton) { this.#updateShowUnusedCustomCssPropertiesButtonText(); } return editor; } /** * Returns a Set containing the list of unused CSS variable TextProperty which shouldn't * be visible. * * @returns {Set} */ #getUnusedCssVariableDeclarations() { const unusedCssVariableDeclarations = new Set(); // No need to go through the declarations if we shouldn't hide unused custom properties if (!this.options.shouldHideUnusedCustomCssProperties) { return unusedCssVariableDeclarations; } // Compute a list of variables that will be visible, as there might be unused variables // that will be visible (e.g. if the user added one in the rules view) for (const prop of this.rule.textProps) { if (prop.isUnusedVariable) { unusedCssVariableDeclarations.add(prop); } } return unusedCssVariableDeclarations; } /** * Handle click on "Show X unused custom CSS properties" button * * @param {Event} e */ #onShowUnusedCustomCssPropertiesButtonClick = e => { e.stopPropagation(); this.#nullifyShowUnusedCustomCssProperties(); for (const prop of this.#unusedCssVariableDeclarations) { if (!prop.invisible) { const editor = new TextPropertyEditor(this, prop, { elementsWithPendingClicks: this.options.elementsWithPendingClicks, }); // Insert at the original declaration index this.propertyList.insertBefore( editor.element, this.propertyList.childNodes[this.rule.textProps.indexOf(prop)] || null ); } } if (typeof this.options.onShowUnusedCustomCssProperties === "function") { this.options.onShowUnusedCustomCssProperties(); } }; /** * Update the text for the "Show X unused custom CSS properties" button, or remove it * if there's no hidden custom properties anymore */ #updateShowUnusedCustomCssPropertiesButtonText() { if (!this.#showUnusedCustomCssPropertiesButton) { return; } const unusedVariablesCount = this.#unusedCssVariableDeclarations.size; if (!unusedVariablesCount) { this.#nullifyShowUnusedCustomCssProperties(); return; } const label = PluralForm.get( unusedVariablesCount, STYLE_INSPECTOR_L10N.getStr("rule.showUnusedCssVariable") ).replace("#1", unusedVariablesCount); this.#showUnusedCustomCssPropertiesButton.replaceChildren(label); } /** * Nullify this.#showUnusedCustomCssPropertiesButton, remove its click event handler * and remove it from the DOM if `removeFromDom` is set to true. * * @param {object} [options] * @param {boolean} [options.removeFromDom] * Should the button be removed from the DOM (defaults to true) */ #nullifyShowUnusedCustomCssProperties({ removeFromDom = true } = {}) { if (!this.#showUnusedCustomCssPropertiesButton) { return; } this.#showUnusedCustomCssPropertiesButton.removeEventListener( "click", this.#onShowUnusedCustomCssPropertiesButtonClick ); if (removeFromDom) { this.#showUnusedCustomCssPropertiesButton.remove(); } this.#showUnusedCustomCssPropertiesButton = null; } /** * Render a given rule selector in this.selectorText element * * @param {string} selector: The selector text to display * @param {number} selectorIndex: Its index in the rule */ #populateSelector(selector, selectorIndex) { if (selectorIndex !== 0) { createChild(this.selectorText, "span", { class: "ruleview-selector-separator", textContent: ", ", }); } const containerClass = "ruleview-selector " + (this.rule.matchedSelectorIndexes.includes(selectorIndex) ? "matched" : "unmatched"); let selectorContainerTitle; if ( typeof this.rule.selector.selectorsSpecificity?.[selectorIndex] !== "undefined" ) { // The specificity that we get from the platform is a single number that we // need to format into the common `(x,y,z)` specificity string. const specificity = this.rule.selector.selectorsSpecificity?.[selectorIndex]; const a = Math.floor(specificity / (1024 * 1024)); const b = Math.floor((specificity % (1024 * 1024)) / 1024); const c = specificity % 1024; selectorContainerTitle = STYLE_INSPECTOR_L10N.getFormatStr( "rule.selectorSpecificity.title", `(${a},${b},${c})` ); } const selectorContainer = createChild(this.selectorText, "span", { class: containerClass, title: selectorContainerTitle, }); const parsedSelector = parsePseudoClassesAndAttributes(selector); for (const selectorText of parsedSelector) { let selectorClass = ""; switch (selectorText.type) { case SELECTOR_ATTRIBUTE: selectorClass = "ruleview-selector-attribute"; break; case SELECTOR_ELEMENT: selectorClass = "ruleview-selector-element"; break; case SELECTOR_PSEUDO_CLASS: selectorClass = [ ...PSEUDO_CLASSES, ...Object.keys(ELEMENT_SPECIFIC_PSEUDO_CLASSES), ].some(pseudo => selectorText.value === pseudo) ? "ruleview-selector-pseudo-class-lock" : "ruleview-selector-pseudo-class"; break; default: break; } createChild(selectorContainer, "span", { textContent: selectorText.value, class: selectorClass, }); } const warningsContainer = this.#createWarningsElementForSelector( selectorIndex, this.rule.domRule.selectorWarnings ); if (warningsContainer) { selectorContainer.append(warningsContainer); } } /** * Programatically add a new property to the rule. * * @param {string} name * Property name. * @param {string} value * Property value. * @param {string} priority * Property priority. * @param {boolean} enabled * True if the property should be enabled. * @param {TextProperty} siblingProp * Optional, property next to which the new property will be added. * @return {TextProperty} * The new property */ addProperty(name, value, priority, enabled, siblingProp) { const prop = this.rule.createProperty( name, value, priority, enabled, siblingProp ); const index = this.rule.textProps.indexOf(prop); const editor = new TextPropertyEditor(this, prop, { elementsWithPendingClicks: this.options.elementsWithPendingClicks, }); // Insert this node before the DOM node that is currently at its new index // in the property list. There is currently one less node in the DOM than // in the property list, so this causes it to appear after siblingProp. // If there is no node at its index, as is the case where this is the last // node being inserted, then this behaves as appendChild. this.propertyList.insertBefore( editor.element, this.propertyList.children[index] ); return prop; } /** * Programatically add a list of new properties to the rule. Focus the UI * to the proper location after adding (either focus the value on the * last property if it is empty, or create a new property and focus it). * * @param {Array} properties * Array of properties, which are objects with this signature: * { * name: {string}, * value: {string}, * priority: {string} * } * @param {TextProperty} siblingProp * Optional, the property next to which all new props should be added. */ addProperties(properties, siblingProp) { if (!properties || !properties.length) { return; } let lastProp = siblingProp; for (const p of properties) { const isCommented = Boolean(p.commentOffsets); const enabled = !isCommented; lastProp = this.addProperty( p.name, p.value, p.priority, enabled, lastProp ); } // Either focus on the last value if incomplete, or start a new one. if (lastProp && lastProp.value.trim() === "") { lastProp.editor.valueSpan.click(); } else { this.newProperty(); } } /** * Create a text input for a property name. If a non-empty property * name is given, we'll create a real TextProperty and add it to the * rule. */ newProperty() { // If we're already creating a new property, ignore this. if (!this.closeBrace.hasAttribute("tabindex")) { return; } // While we're editing a new property, it doesn't make sense to start a second new // property editor, so disable focusing the close brace for now. this.closeBrace.removeAttribute("tabindex"); // We also need to make the "Show Unused Variables" button non-focusable so hitting // Tab while focused in the new property editor will move the focus to the next rule // selector editor. if (this.#showUnusedCustomCssPropertiesButton) { this.#showUnusedCustomCssPropertiesButton.setAttribute("tabindex", "-1"); } this.newPropItem = createChild(this.propertyList, "div", { class: "ruleview-property ruleview-newproperty", role: "listitem", }); this.newPropSpan = createChild(this.newPropItem, "span", { class: "ruleview-propertyname", tabindex: "0", }); this.multipleAddedProperties = null; this.editor = new InplaceEditor({ element: this.newPropSpan, done: this.#onNewProperty, // (Shift+)Tab will move the focus to the previous/next editable field focusEditableFieldAfterApply: true, focusEditableFieldContainerSelector: ".ruleview-rule", destroy: this.#newPropertyDestroy, advanceChars: ":", contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY, popup: this.ruleView.popup, cssProperties: this.rule.cssProperties, inputAriaLabel: NEW_PROPERTY_NAME_INPUT_LABEL, getCssVariables: () => this.rule.elementStyle.getAllCustomProperties(this.rule.pseudoElement), }); // Auto-close the input if multiple rules get pasted into new property. this.editor.input.addEventListener( "paste", blurOnMultipleProperties(this.rule.cssProperties) ); } /** * Called when the new property input has been dismissed. * * @param {string} value * The value in the editor. * @param {boolean} commit * True if the value should be committed. */ #onNewProperty = (value, commit) => { if (!value || !commit) { return; } // parseDeclarations allows for name-less declarations, but in the present // case, we're creating a new declaration, it doesn't make sense to accept // these entries this.multipleAddedProperties = parseNamedDeclarations( this.rule.cssProperties.isKnown, value, true ); // Blur the editor field now and deal with adding declarations later when // the field gets destroyed (see #newPropertyDestroy) this.editor.input.blur(); this.telemetry.recordEvent("edit_rule", "ruleview"); }; /** * Called when the new property editor is destroyed. * This is where the properties (type TextProperty) are actually being * added, since we want to wait until after the inplace editor `destroy` * event has been fired to keep consistent UI state. */ #newPropertyDestroy = () => { // We're done, make the close brace and "Show unused variable" button focusable again. this.closeBrace.setAttribute("tabindex", "0"); if (this.#showUnusedCustomCssPropertiesButton) { this.#showUnusedCustomCssPropertiesButton.removeAttribute("tabindex"); } this.propertyList.removeChild(this.newPropItem); delete this.newPropItem; delete this.newPropSpan; // If properties were added, we want to focus the proper element. // If the last new property has no value, focus the value on it. // Otherwise, start a new property and focus that field. if (this.multipleAddedProperties && this.multipleAddedProperties.length) { this.addProperties(this.multipleAddedProperties); } }; /** * Called when the selector'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. */ #onSelectorDone = async (value, commit, direction) => { if ( !commit || this.isEditing || value === "" || value === this.rule.selectorText ) { return; } const ruleView = this.ruleView; const elementStyle = ruleView.elementStyle; const element = elementStyle.element; this.isEditing = true; // Remove highlighter for the previous selector. const computedSelector = this.rule.domRule.computedSelector; if (this.ruleView.isSelectorHighlighted(computedSelector)) { await this.ruleView.toggleSelectorHighlighter( this.rule, computedSelector ); } try { const response = await this.rule.domRule.modifySelector(element, value); // Modifying the selector might have removed the element (e.g. for pseudo element) if (!element.actorID) { return; } // We recompute the list of applied styles, because editing a // selector might cause this rule's position to change. const applied = await elementStyle.pageStyle.getApplied(element, { inherited: true, matchedSelectors: true, filter: elementStyle.showUserAgentStyles ? "ua" : undefined, }); // The element might have been removed while we were trying to get the applied declarations if (!element.actorID) { return; } this.isEditing = false; const { ruleProps, isMatching } = response; if (!ruleProps) { // Notify for changes, even when nothing changes, // just to allow tests being able to track end of this request. ruleView.emit("ruleview-invalid-selector"); return; } ruleProps.isUnmatched = !isMatching; const newRule = new Rule(elementStyle, ruleProps); const editor = new RuleEditor(ruleView, newRule, { elementsWithPendingClicks: new Set(), }); newRule.editor = editor; const rules = elementStyle.rules; let newRuleIndex = applied.findIndex(r => r.rule == ruleProps.rule); const oldIndex = rules.indexOf(this.rule); // If the selector no longer matches, then we leave the rule in // the same relative position. if (newRuleIndex === -1) { newRuleIndex = oldIndex; } // Remove the old rule and insert the new rule. rules.splice(oldIndex, 1); rules.splice(newRuleIndex, 0, newRule); elementStyle.notifyChanged(); elementStyle.onRuleUpdated(); // We install the new editor in place of the old -- you might // think we would replicate the list-modification logic above, // but that is complicated due to the way the UI installs // pseudo-element rules and the like. this.element.parentNode.replaceChild(editor.element, this.element); // As the rules elements will be replaced, and given that the inplace-editor doesn't // wait for this `done` callback to be resolved, the focus management we do there // will be useless as this specific code will usually happen later (and the focused // element might be replaced). // Because of this, we need to handle setting the focus ourselves from here. editor.#moveSelectorFocus(direction); // Destroy the current editor as it got replaced by the fresh instance this.destroy(); } catch (err) { this.isEditing = false; promiseWarn(err); } }; /** * Handle moving the focus change after a Tab keypress in the selector inplace editor. * * @param {number} direction * The move focus direction number. */ #moveSelectorFocus(direction) { if (!direction || direction === Services.focus.MOVEFOCUS_BACKWARD) { return; } if (this.rule.textProps.length) { this.rule.textProps[0].editor.nameSpan.click(); } else { this.propertyList.click(); } } } module.exports = RuleEditor;