summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/rules/views
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/client/inspector/rules/views
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/inspector/rules/views')
-rw-r--r--devtools/client/inspector/rules/views/class-list-previewer.js310
-rw-r--r--devtools/client/inspector/rules/views/moz.build10
-rw-r--r--devtools/client/inspector/rules/views/registered-property-editor.js182
-rw-r--r--devtools/client/inspector/rules/views/rule-editor.js1010
-rw-r--r--devtools/client/inspector/rules/views/text-property-editor.js1637
5 files changed, 3149 insertions, 0 deletions
diff --git a/devtools/client/inspector/rules/views/class-list-previewer.js b/devtools/client/inspector/rules/views/class-list-previewer.js
new file mode 100644
index 0000000000..e4e99bedde
--- /dev/null
+++ b/devtools/client/inspector/rules/views/class-list-previewer.js
@@ -0,0 +1,310 @@
+/* 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 ClassList = require("resource://devtools/client/inspector/rules/models/class-list.js");
+
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js");
+const { debounce } = require("resource://devtools/shared/debounce.js");
+
+/**
+ * This UI widget shows a textfield and a series of checkboxes in the rule-view. It is
+ * used to toggle classes on the current node selection, and add new classes.
+ */
+class ClassListPreviewer {
+ /*
+ * @param {Inspector} inspector
+ * The current inspector instance.
+ * @param {DomNode} containerEl
+ * The element in the rule-view where the widget should go.
+ */
+ constructor(inspector, containerEl) {
+ this.inspector = inspector;
+ this.containerEl = containerEl;
+ this.model = new ClassList(inspector);
+
+ this.onNewSelection = this.onNewSelection.bind(this);
+ this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this);
+ this.onKeyDown = this.onKeyDown.bind(this);
+ this.onAddElementInputModified = debounce(
+ this.onAddElementInputModified,
+ 75,
+ this
+ );
+ this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this);
+ this.onNodeFrontWillUnset = this.onNodeFrontWillUnset.bind(this);
+ this.onAutocompleteClassHovered = debounce(
+ this.onAutocompleteClassHovered,
+ 75,
+ this
+ );
+ this.onAutocompleteClosed = this.onAutocompleteClosed.bind(this);
+
+ // Create the add class text field.
+ this.addEl = this.doc.createElement("input");
+ this.addEl.classList.add("devtools-textinput");
+ this.addEl.classList.add("add-class");
+ this.addEl.setAttribute(
+ "placeholder",
+ L10N.getStr("inspector.classPanel.newClass.placeholder")
+ );
+ this.addEl.addEventListener("keydown", this.onKeyDown);
+ this.addEl.addEventListener("input", this.onAddElementInputModified);
+ this.containerEl.appendChild(this.addEl);
+
+ // Create the class checkboxes container.
+ this.classesEl = this.doc.createElement("div");
+ this.classesEl.classList.add("classes");
+ this.containerEl.appendChild(this.classesEl);
+
+ // Create the autocomplete popup
+ this.autocompletePopup = new AutocompletePopup(this.inspector.toolbox.doc, {
+ listId: "inspector_classListPreviewer_autocompletePopupListBox",
+ position: "bottom",
+ autoSelect: true,
+ useXulWrapper: true,
+ input: this.addEl,
+ onClick: (e, item) => {
+ if (item) {
+ this.addEl.value = item.label;
+ this.autocompletePopup.hidePopup();
+ this.autocompletePopup.clearItems();
+ this.model.previewClass(item.label);
+ }
+ },
+ onSelect: item => {
+ if (item) {
+ this.onAutocompleteClassHovered(item?.label);
+ }
+ },
+ });
+
+ // Start listening for interesting events.
+ this.inspector.selection.on("new-node-front", this.onNewSelection);
+ this.inspector.selection.on(
+ "node-front-will-unset",
+ this.onNodeFrontWillUnset
+ );
+ this.containerEl.addEventListener("input", this.onCheckBoxChanged);
+ this.model.on("current-node-class-changed", this.onCurrentNodeClassChanged);
+ this.autocompletePopup.on("popup-closed", this.onAutocompleteClosed);
+
+ this.onNewSelection();
+ }
+
+ destroy() {
+ this.inspector.selection.off("new-node-front", this.onNewSelection);
+ this.inspector.selection.off(
+ "node-front-will-unset",
+ this.onNodeFrontWillUnset
+ );
+ this.autocompletePopup.off("popup-closed", this.onAutocompleteClosed);
+ this.addEl.removeEventListener("keydown", this.onKeyDown);
+ this.addEl.removeEventListener("input", this.onAddElementInputModified);
+ this.containerEl.removeEventListener("input", this.onCheckBoxChanged);
+
+ this.autocompletePopup.destroy();
+
+ this.containerEl.innerHTML = "";
+
+ this.model.destroy();
+ this.containerEl = null;
+ this.inspector = null;
+ this.addEl = null;
+ this.classesEl = null;
+ }
+
+ get doc() {
+ return this.containerEl.ownerDocument;
+ }
+
+ /**
+ * Render the content of the panel. You typically don't need to call this as the panel
+ * renders itself on inspector selection changes.
+ */
+ render() {
+ this.classesEl.innerHTML = "";
+
+ for (const { name, isApplied } of this.model.currentClasses) {
+ const checkBox = this.renderCheckBox(name, isApplied);
+ this.classesEl.appendChild(checkBox);
+ }
+
+ if (!this.model.currentClasses.length) {
+ this.classesEl.appendChild(this.renderNoClassesMessage());
+ }
+ }
+
+ /**
+ * Render a single checkbox for a given classname.
+ *
+ * @param {String} name
+ * The name of this class.
+ * @param {Boolean} isApplied
+ * Is this class currently applied on the DOM node.
+ * @return {DOMNode} The DOM element for this checkbox.
+ */
+ renderCheckBox(name, isApplied) {
+ const box = this.doc.createElement("input");
+ box.setAttribute("type", "checkbox");
+ if (isApplied) {
+ box.setAttribute("checked", "checked");
+ }
+ box.dataset.name = name;
+
+ const labelWrapper = this.doc.createElement("label");
+ labelWrapper.setAttribute("title", name);
+ labelWrapper.appendChild(box);
+
+ // A child element is required to do the ellipsis.
+ const label = this.doc.createElement("span");
+ label.textContent = name;
+ labelWrapper.appendChild(label);
+
+ return labelWrapper;
+ }
+
+ /**
+ * Render the message displayed in the panel when the current element has no classes.
+ *
+ * @return {DOMNode} The DOM element for the message.
+ */
+ renderNoClassesMessage() {
+ const msg = this.doc.createElement("p");
+ msg.classList.add("no-classes");
+ msg.textContent = L10N.getStr("inspector.classPanel.noClasses");
+ return msg;
+ }
+
+ /**
+ * Focus the add-class text field.
+ */
+ focusAddClassField() {
+ if (this.addEl) {
+ this.addEl.focus();
+ }
+ }
+
+ onCheckBoxChanged({ target }) {
+ if (!target.dataset.name) {
+ return;
+ }
+
+ this.model.setClassState(target.dataset.name, target.checked).catch(e => {
+ // Only log the error if the panel wasn't destroyed in the meantime.
+ if (this.containerEl) {
+ console.error(e);
+ }
+ });
+ }
+
+ onKeyDown(event) {
+ // If the popup is already open, all the keyboard interaction are handled
+ // directly by the popup component.
+ if (this.autocompletePopup.isOpen) {
+ return;
+ }
+
+ // Open the autocomplete popup on Ctrl+Space / ArrowDown (when the input isn't empty)
+ if (
+ (this.addEl.value && event.key === " " && event.ctrlKey) ||
+ event.key === "ArrowDown"
+ ) {
+ this.onAddElementInputModified();
+ return;
+ }
+
+ if (this.addEl.value !== "" && event.key === "Enter") {
+ this.addClassName(this.addEl.value);
+ }
+ }
+
+ async onAddElementInputModified() {
+ const newValue = this.addEl.value;
+
+ // if the input is empty, let's close the popup, if it was open.
+ if (newValue === "") {
+ if (this.autocompletePopup.isOpen) {
+ this.autocompletePopup.hidePopup();
+ this.autocompletePopup.clearItems();
+ } else {
+ this.model.previewClass("");
+ }
+ return;
+ }
+
+ // Otherwise, we need to update the popup items to match the new input.
+ let items = [];
+ try {
+ const classNames = await this.model.getClassNames(newValue);
+ if (!this.autocompletePopup.isOpen) {
+ this._previewClassesBeforeAutocompletion =
+ this.model.previewClasses.map(previewClass => previewClass.className);
+ }
+ items = classNames.map(className => {
+ return {
+ preLabel: className.substring(0, newValue.length),
+ label: className,
+ };
+ });
+ } catch (e) {
+ // If there was an error while retrieving the classNames, we'll simply NOT show the
+ // popup, which is okay.
+ console.warn("Error when calling getClassNames", e);
+ }
+
+ if (!items.length || (items.length == 1 && items[0].label === newValue)) {
+ this.autocompletePopup.clearItems();
+ await this.autocompletePopup.hidePopup();
+ this.model.previewClass(newValue);
+ } else {
+ this.autocompletePopup.setItems(items);
+ this.autocompletePopup.openPopup();
+ }
+ }
+
+ async addClassName(className) {
+ try {
+ await this.model.addClassName(className);
+ this.render();
+ this.addEl.value = "";
+ } catch (e) {
+ // Only log the error if the panel wasn't destroyed in the meantime.
+ if (this.containerEl) {
+ console.error(e);
+ }
+ }
+ }
+
+ onNewSelection() {
+ this.render();
+ }
+
+ onCurrentNodeClassChanged() {
+ this.render();
+ }
+
+ onNodeFrontWillUnset() {
+ this.model.eraseClassPreview();
+ this.addEl.value = "";
+ }
+
+ onAutocompleteClassHovered(autocompleteItemLabel = "") {
+ if (this.autocompletePopup.isOpen) {
+ this.model.previewClass(autocompleteItemLabel);
+ }
+ }
+
+ onAutocompleteClosed() {
+ const inputValue = this.addEl.value;
+ this.model.previewClass(inputValue);
+ }
+}
+
+module.exports = ClassListPreviewer;
diff --git a/devtools/client/inspector/rules/views/moz.build b/devtools/client/inspector/rules/views/moz.build
new file mode 100644
index 0000000000..6dcb5aa05f
--- /dev/null
+++ b/devtools/client/inspector/rules/views/moz.build
@@ -0,0 +1,10 @@
+# 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/.
+
+DevToolsModules(
+ "class-list-previewer.js",
+ "registered-property-editor.js",
+ "rule-editor.js",
+ "text-property-editor.js",
+)
diff --git a/devtools/client/inspector/rules/views/registered-property-editor.js b/devtools/client/inspector/rules/views/registered-property-editor.js
new file mode 100644
index 0000000000..d89c0fe89d
--- /dev/null
+++ b/devtools/client/inspector/rules/views/registered-property-editor.js
@@ -0,0 +1,182 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ appendText,
+ createChild,
+} = require("resource://devtools/client/inspector/shared/utils.js");
+
+const INDENT_SIZE = 2;
+const INDENT_STR = " ".repeat(INDENT_SIZE);
+
+/**
+ * RegisteredPropertyEditor creates a list of TextPropertyEditors for a given
+ * CSS registered property propertyDefinition that can be rendered in the Rules view.
+ *
+ * @param {CssRuleView} ruleView
+ * The CssRuleView containg the document holding this rule editor.
+ * @param {Rule} rule
+ * The Rule object we're editing.
+ */
+class RegisteredPropertyEditor extends EventEmitter {
+ /**
+ * @param {CssRuleView} ruleView
+ * The CssRuleView containing the document holding this rule editor.
+ * @param {Object} propertyDefinition
+ * The property definition data as returned by PageStyleActor's getRegisteredProperties
+ */
+ constructor(ruleView, propertyDefinition) {
+ super();
+
+ this.#doc = ruleView.styleDocument;
+ this.#propertyDefinition = propertyDefinition;
+ this.#createElement();
+ }
+
+ #doc;
+ #propertyDefinition;
+ // The HTMLElement that will represent the registered property. Populated in #createElement.
+ element = null;
+
+ #createElement() {
+ this.element = this.#doc.createElement("div");
+ this.element.className = "ruleview-rule devtools-monospace";
+ this.element.setAttribute("uneditable", true);
+ this.element.setAttribute("unmatched", false);
+ this.element.setAttribute("data-name", this.#propertyDefinition.name);
+
+ // Give a relative position for the inplace editor's measurement
+ // span to be placed absolutely against.
+ this.element.style.position = "relative";
+
+ const code = createChild(this.element, "code", {
+ class: "ruleview-code",
+ });
+
+ const header = createChild(code, "header", {});
+
+ this.propertyName = createChild(header, "span", {
+ class: "ruleview-registered-property-name",
+ textContent: this.#propertyDefinition.name,
+ });
+
+ 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(code, "div", {
+ class: "ruleview-propertylist",
+ role: "list",
+ });
+
+ this.#populateProperties();
+
+ this.closeBrace = createChild(code, "div", {
+ class: "ruleview-ruleclose",
+ textContent: "}",
+ });
+ }
+
+ /**
+ * Sets the content of this.#propertyList with the contents of the registered property .
+ */
+ #populateProperties() {
+ const properties = [
+ {
+ name: "syntax",
+ value: `"${this.#propertyDefinition.syntax}"`,
+ },
+ {
+ name: "inherits",
+ value: this.#propertyDefinition.inherits,
+ },
+ ];
+
+ // The initial value may not be set, when syntax is "*", so let's only display
+ // it when it is actually set.
+ if (this.#propertyDefinition.initialValue !== null) {
+ // For JS-defined properties, we want to display them in the same syntax that
+ // was used in CSS.registerProperty (so we'll show `initialValue` and not `initial-value`).
+ properties.push({
+ name: this.#propertyDefinition.fromJS
+ ? "initialValue"
+ : "initial-value",
+ value: this.#propertyDefinition.fromJS
+ ? `"${this.#propertyDefinition.initialValue}"`
+ : this.#propertyDefinition.initialValue,
+ });
+ }
+
+ // When the property is registered with CSS.registerProperty, we want to match the
+ // object shape of the parameter, so include the "name" property.
+ if (this.#propertyDefinition.fromJS) {
+ properties.unshift({
+ name: "name",
+ value: `"${this.#propertyDefinition.name}"`,
+ });
+ }
+
+ for (const { name, value } of properties) {
+ // XXX: We could use the TextPropertyEditor here.
+ // Pros:
+ // - we'd get the similar markup, so styling would be easier
+ // - the value would be properly parsed so our various swatches and popups would work
+ // out of the box
+ // - rule view filtering would also work out of the box
+ // Cons:
+ // - it is quite tied with the Rules view regular rule, which mean we'd have
+ // to modify it to accept registered properties.
+
+ const element = createChild(this.propertyList, "div", {
+ role: "listitem",
+ });
+ const container = createChild(element, "div", {
+ class: "ruleview-propertycontainer",
+ });
+
+ createChild(container, "span", {
+ class: "ruleview-rule-indent clipboard-only",
+ textContent: INDENT_STR,
+ });
+
+ const nameContainer = createChild(container, "span", {
+ class: "ruleview-namecontainer",
+ });
+
+ createChild(nameContainer, "span", {
+ class: "ruleview-propertyname theme-fg-color3",
+ textContent: name,
+ });
+
+ appendText(nameContainer, ": ");
+
+ // Create a span that will hold the property and semicolon.
+ // Use this span to create a slightly larger click target
+ // for the value.
+ const valueContainer = createChild(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.
+ createChild(valueContainer, "span", {
+ class: "ruleview-propertyvalue theme-fg-color1",
+ textContent: value,
+ });
+
+ appendText(valueContainer, this.#propertyDefinition.fromJS ? "," : ";");
+
+ this.propertyList.appendChild(element);
+ }
+ }
+}
+
+module.exports = RegisteredPropertyEditor;
diff --git a/devtools/client/inspector/rules/views/rule-editor.js b/devtools/client/inspector/rules/views/rule-editor.js
new file mode 100644
index 0000000000..93e24f0946
--- /dev/null
+++ b/devtools/client/inspector/rules/views/rule-editor.js
@@ -0,0 +1,1010 @@
+/* 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,
+} = require("resource://devtools/shared/css/constants.js");
+const {
+ style: { ELEMENT_STYLE },
+} = 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
+);
+
+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);
+
+loader.lazyGetter(this, "NEW_PROPERTY_NAME_INPUT_LABEL", function () {
+ return STYLE_INSPECTOR_L10N.getStr("rule.newPropertyName.label");
+});
+
+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.
+ *
+ * @param {CssRuleView} ruleView
+ * The CssRuleView containg the document holding this rule editor.
+ * @param {Rule} rule
+ * The Rule object we're editing.
+ */
+function RuleEditor(ruleView, rule) {
+ EventEmitter.decorate(this);
+
+ this.ruleView = ruleView;
+ this.doc = this.ruleView.styleDocument;
+ this.toolbox = this.ruleView.inspector.toolbox;
+ this.telemetry = this.toolbox.telemetry;
+ this.rule = rule;
+
+ this.isEditable = !rule.isSystem;
+ // Flag that blocks updates of the selector and properties when it is
+ // being edited
+ this.isEditing = false;
+
+ this._onNewProperty = this._onNewProperty.bind(this);
+ this._newPropertyDestroy = this._newPropertyDestroy.bind(this);
+ this._onSelectorDone = this._onSelectorDone.bind(this);
+ this._locationChanged = this._locationChanged.bind(this);
+ this.updateSourceLink = this.updateSourceLink.bind(this);
+ this._onToolChanged = this._onToolChanged.bind(this);
+ this._updateLocation = this._updateLocation.bind(this);
+ this._onSourceClick = this._onSourceClick.bind(this);
+
+ this.rule.domRule.on("location-changed", this._locationChanged);
+ this.toolbox.on("tool-registered", this._onToolChanged);
+ this.toolbox.on("tool-unregistered", this._onToolChanged);
+
+ this._create();
+}
+
+RuleEditor.prototype = {
+ destroy() {
+ this.rule.domRule.off("location-changed");
+ this.toolbox.off("tool-registered", this._onToolChanged);
+ this.toolbox.off("tool-unregistered", this._onToolChanged);
+
+ if (this._unsubscribeSourceMap) {
+ this._unsubscribeSourceMap();
+ }
+ },
+
+ get sourceMapURLService() {
+ if (!this._sourceMapURLService) {
+ // sourceMapURLService is a lazy getter in the toolbox.
+ this._sourceMapURLService = this.toolbox.sourceMapURLService;
+ }
+
+ return this._sourceMapURLService;
+ },
+
+ get isSelectorEditable() {
+ const trait =
+ this.isEditable &&
+ this.rule.domRule.type !== ELEMENT_STYLE &&
+ this.rule.domRule.type !== CSSRule.KEYFRAME_RULE;
+
+ // Do not allow editing anonymousselectors until we can
+ // detect mutations on pseudo elements in Bug 1034110.
+ return trait && !this.rule.elementStyle.element.isAnonymous;
+ },
+
+ _create() {
+ this.element = this.doc.createElement("div");
+ this.element.className = "ruleview-rule devtools-monospace";
+ this.element.dataset.ruleId = this.rule.domRule.actorID;
+ this.element.setAttribute("uneditable", !this.isEditable);
+ this.element.setAttribute("unmatched", this.rule.isUnmatched);
+ 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.
+ this.source = createChild(this.element, "div", {
+ class: "ruleview-rule-source theme-link",
+ });
+ this.source.addEventListener("click", this._onSourceClick);
+
+ const sourceLabel = this.doc.createElement("span");
+ 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 : ""
+ }`,
+ });
+
+ // We can't use a button, otherwise a line break is added when copy/pasting the rule
+ const jumpToNodeButton = createChild(selectorContainer, "span", {
+ class: "open-inspector",
+ role: "button",
+ 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.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 theme-link",
+ role: "list",
+ });
+ this.ancestorDataEl.append(ancestorsFrag);
+ }
+
+ const code = createChild(this.element, "div", {
+ class: "ruleview-code",
+ });
+
+ const header = createChild(code, "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.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,
+ });
+ }
+
+ if (this.rule.domRule.type !== CSSRule.KEYFRAME_RULE) {
+ let selector = "";
+ let desugaredSelector = "";
+ if (this.rule.domRule.selectors) {
+ // This is a "normal" rule with a selector.
+ selector = this.rule.domRule.selectors.join(", ");
+ desugaredSelector = this.rule.domRule.desugaredSelectors?.join(", ");
+ // Otherwise, the rule is either inherited or inline, and selectors will
+ // be computed on demand when the highlighter is requested.
+ }
+
+ const isHighlighted = this.ruleView.isSelectorHighlighted(selector);
+ // 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": desugaredSelector,
+ 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(code, "div", {
+ class: "ruleview-propertylist",
+ role: "list",
+ });
+
+ this.populate();
+
+ this.closeBrace = createChild(code, "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(code, "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;
+
+ code.addEventListener("mousedown", () => {
+ this._ruleViewIsEditing = this.ruleView.isEditing;
+ });
+
+ code.addEventListener("click", event => {
+ 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<Object>} 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() {
+ // When the source editor is registered, update the source links
+ // to be clickable; and if it is unregistered, update the links to
+ // be unclickable. However, some links are never clickable, so
+ // filter those out first.
+ if (this.source.getAttribute("unselectable") === "permanent") {
+ // Nothing.
+ } else 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() {
+ 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 title = displayURL ? displayURL : sourceTextContent;
+ if (line > 0) {
+ sourceTextContent += ":" + line;
+ title += ":" + line;
+ }
+
+ const sourceLabel = this.element.querySelector(
+ ".ruleview-rule-source-label"
+ );
+ sourceLabel.setAttribute("title", title);
+ sourceLabel.setAttribute("data-url", displayURL);
+ sourceLabel.textContent = sourceTextContent;
+ },
+
+ updateSourceLink() {
+ 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("data-url", this.rule.sheet?.href);
+ } else {
+ this._updateLocation(null);
+ }
+
+ if (
+ this.rule.sheet &&
+ !this.rule.isSystem &&
+ this.rule.domRule.type !== ELEMENT_STYLE
+ ) {
+ // 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();
+ } else if (this.rule.domRule.type === ELEMENT_STYLE) {
+ this.source.setAttribute("unselectable", "permanent");
+ } else {
+ // 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.
+ while (this.selectorText.hasChildNodes()) {
+ this.selectorText.removeChild(this.selectorText.lastChild);
+ }
+
+ // 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.selectorText.textContent = this.rule.selectorText;
+ } else if (this.rule.domRule.type === CSSRule.KEYFRAME_RULE) {
+ this.selectorText.textContent = this.rule.domRule.keyText;
+ } else {
+ const desugaredSelectors = this.rule.domRule.desugaredSelectors;
+ this.rule.domRule.selectors.forEach((selector, i) => {
+ if (i !== 0) {
+ createChild(this.selectorText, "span", {
+ class: "ruleview-selector-separator",
+ textContent: ", ",
+ });
+ }
+
+ let containerClass = "ruleview-selector ";
+
+ // Only add matched/unmatched class when the rule does have some matched
+ // selectors. We don't always have some (e.g. rules for pseudo elements)
+ if (this.rule.matchedDesugaredSelectors.length) {
+ const desugaredSelector = desugaredSelectors[i];
+ const matchedSelector =
+ this.rule.matchedDesugaredSelectors.includes(desugaredSelector);
+ containerClass += matchedSelector ? "matched" : "unmatched";
+ }
+
+ const selectorContainer = createChild(this.selectorText, "span", {
+ class: containerClass,
+ });
+
+ 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.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(
+ i,
+ this.rule.domRule.selectorWarnings
+ );
+ if (warningsContainer) {
+ selectorContainer.append(warningsContainer);
+ }
+ });
+ }
+
+ 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);
+ }
+
+ while (this.propertyList.hasChildNodes()) {
+ this.propertyList.removeChild(this.propertyList.lastChild);
+ }
+ }
+
+ for (const prop of this.rule.textProps) {
+ if (!prop.editor && !prop.invisible) {
+ const editor = new TextPropertyEditor(this, prop);
+ 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 (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);
+ }
+ }
+ },
+
+ /**
+ * 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);
+
+ // 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");
+
+ 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,
+ destroy: this._newPropertyDestroy,
+ advanceChars: ":",
+ contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY,
+ popup: this.ruleView.popup,
+ cssProperties: this.rule.cssProperties,
+ inputAriaLabel: NEW_PROPERTY_NAME_INPUT_LABEL,
+ cssVariables: 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 focusable again.
+ this.closeBrace.setAttribute("tabindex", "0");
+
+ 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.
+ * @param {Number} key
+ * The event keyCode that trigger the editor to close
+ */
+ async _onSelectorDone(value, commit, direction, key) {
+ 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.
+ if (this.ruleView.isSelectorHighlighted(this.rule.selectorText)) {
+ await this.ruleView.toggleSelectorHighlighter(this.rule.selectorText);
+ }
+
+ try {
+ const response = await this.rule.domRule.modifySelector(element, value);
+
+ // 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,
+ });
+
+ 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);
+ 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._changed();
+ 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);
+ } 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;
diff --git a/devtools/client/inspector/rules/views/text-property-editor.js b/devtools/client/inspector/rules/views/text-property-editor.js
new file mode 100644
index 0000000000..8546417cfb
--- /dev/null
+++ b/devtools/client/inspector/rules/views/text-property-editor.js
@@ -0,0 +1,1637 @@
+/* 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,
+ l10nFormatStr,
+} = 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
+);
+loader.lazyGetter(this, "PROPERTY_NAME_INPUT_LABEL", function () {
+ return l10n("rule.propertyName.label");
+});
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
+});
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+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, "div");
+ this.element.setAttribute("role", "listitem");
+ 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",
+ });
+
+ const indent =
+ ((this.ruleEditor.rule.domRule.ancestorData.length || 0) + 1) * 2;
+ createChild(this.container, "span", {
+ class: "ruleview-rule-indent clipboard-only",
+ textContent: " ".repeat(indent),
+ });
+
+ // The enable checkbox will disable or enable the rule.
+ this.enable = createChild(this.container, "input", {
+ type: "checkbox",
+ class: "ruleview-enableproperty",
+ title: l10nFormatStr("rule.propertyToggle.label", this.prop.name),
+ });
+
+ 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: "",
+ });
+
+ 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, "button", {
+ 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();
+ }
+ });
+
+ const cssVariables = this.rule.elementStyle.getAllCustomProperties(
+ this.rule.pseudoElement
+ );
+
+ 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,
+ cssVariables,
+ // (Shift+)Tab will move the focus to the previous/next editable field (so property value
+ // or new selector).
+ 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,
+ inputAriaLabel: PROPERTY_NAME_INPUT_LABEL,
+ });
+
+ // 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,
+ getGridLineNames: this.getGridlineNames,
+ showSuggestCompletionOnEmpty: true,
+ // (Shift+)Tab will move the focus to the previous/next editable field (so property name,
+ // or new property).
+ 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,
+ // Label the value input with the name span so screenreader users know what this
+ // applies to.
+ inputAriaLabelledBy: this.nameSpan.id,
+ });
+ }
+ },
+
+ /**
+ * 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;
+ this.enable.setAttribute(
+ "title",
+ l10nFormatStr("rule.propertyToggle.label", 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.
+ useDefaultColorUnit: !propDirty,
+ defaultColorUnit: this.ruleView.inspector.defaultColorUnit,
+ 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.addEventListener("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();
+ 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.
+ * @param {Number} key
+ * The event keyCode that trigger the editor to close
+ */
+ _onNameDone(value, commit, direction, key) {
+ 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);
+ }
+ }
+
+ if (this.angleSwatchSpans && this.angleSwatchSpans.length) {
+ for (const span of this.angleSwatchSpans) {
+ span.removeEventListener("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.
+ * @param {Number} key
+ * The event keyCode that trigger the editor to close
+ */
+ _onValueDone(value = "", commit, direction, key) {
+ 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 =
+ /^(?<value>[+-]?(\d*\.)?\d+(e[+-]?\d+)?)(?<unit>(%|[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;