Source code

Revision control

Copy as Markdown

Other Tools

/* 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);
const COMPONENT_PROPERTIES = "devtools/client/locales/components.properties";
const COMPONENT_L10N = new LocalizationHelper(COMPONENT_PROPERTIES);
loader.lazyGetter(this, "NEW_PROPERTY_NAME_INPUT_LABEL", function () {
return STYLE_INSPECTOR_L10N.getStr("rule.newPropertyName.label");
});
const 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);
// inline style are not visible in the StyleEditor, so don't create an actual link
// element for their location.
const sourceLabel = this.doc.createElement(
this.rule.domRule.type === ELEMENT_STYLE ? "span" : "a"
);
sourceLabel.classList.add("ruleview-rule-source-label");
this.source.appendChild(sourceLabel);
this.updateSourceLink();
if (this.rule.domRule.ancestorData.length) {
const ancestorsFrag = this.doc.createDocumentFragment();
this.rule.domRule.ancestorData.forEach((ancestorData, index) => {
const ancestorItem = this.doc.createElement("div");
ancestorItem.setAttribute("role", "listitem");
ancestorsFrag.append(ancestorItem);
ancestorItem.setAttribute("data-ancestor-index", index);
ancestorItem.classList.add("ruleview-rule-ancestor");
if (ancestorData.type) {
ancestorItem.classList.add(ancestorData.type);
}
// Indent each parent selector
if (index) {
createChild(ancestorItem, "span", {
class: "ruleview-rule-indent",
textContent: INDENT_STR.repeat(index),
});
}
const selectorContainer = createChild(ancestorItem, "span", {
class: "ruleview-rule-ancestor-selectorcontainer",
});
if (ancestorData.type == "container") {
ancestorItem.classList.add("container-query", "has-tooltip");
createChild(selectorContainer, "span", {
class: "container-query-declaration",
textContent: `@container${
ancestorData.containerName ? " " + ancestorData.containerName : ""
}`,
});
const jumpToNodeButton = createChild(selectorContainer, "button", {
class: "open-inspector",
title: l10n("rule.containerQuery.selectContainerButton.tooltip"),
});
let containerNodeFront;
const getNodeFront = async () => {
if (!containerNodeFront) {
const res = await this.rule.domRule.getQueryContainerForNode(
index,
this.rule.inherited ||
this.ruleView.inspector.selection.nodeFront
);
containerNodeFront = res.node;
}
return containerNodeFront;
};
jumpToNodeButton.addEventListener("click", async () => {
const front = await getNodeFront();
if (!front) {
return;
}
this.ruleView.inspector.selection.setNodeFront(front);
await this.ruleView.inspector.highlighters.hideHighlighterType(
this.ruleView.inspector.highlighters.TYPES.BOXMODEL
);
});
ancestorItem.addEventListener("mouseenter", async () => {
const front = await getNodeFront();
if (!front) {
return;
}
await this.ruleView.inspector.highlighters.showHighlighterTypeForNode(
this.ruleView.inspector.highlighters.TYPES.BOXMODEL,
front
);
});
ancestorItem.addEventListener("mouseleave", async () => {
await this.ruleView.inspector.highlighters.hideHighlighterType(
this.ruleView.inspector.highlighters.TYPES.BOXMODEL
);
});
createChild(selectorContainer, "span", {
// Add a space between the container name (or @container if there's no name)
// and the query so the title, which is computed from the DOM, displays correctly.
textContent: " " + ancestorData.containerQuery,
});
} else if (ancestorData.type == "layer") {
selectorContainer.append(
this.doc.createTextNode(
`@layer${ancestorData.value ? " " + ancestorData.value : ""}`
)
);
} else if (ancestorData.type == "media") {
selectorContainer.append(
this.doc.createTextNode(`@media ${ancestorData.value}`)
);
} else if (ancestorData.type == "supports") {
selectorContainer.append(
this.doc.createTextNode(`@supports ${ancestorData.conditionText}`)
);
} else if (ancestorData.type == "import") {
selectorContainer.append(
this.doc.createTextNode(`@import ${ancestorData.value}`)
);
} else if (ancestorData.type == "scope") {
let text = `@scope`;
if (ancestorData.start) {
text += ` (${ancestorData.start})`;
if (ancestorData.end) {
text += ` to (${ancestorData.end})`;
}
}
selectorContainer.append(this.doc.createTextNode(text));
} else if (ancestorData.type == "starting-style") {
selectorContainer.append(this.doc.createTextNode(`@starting-style`));
} else if (ancestorData.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) {
// This is a "normal" rule with a selector.
let computedSelector = "";
if (this.rule.domRule.selectors) {
computedSelector = this.rule.domRule.computedSelector;
// Otherwise, the rule is either inherited or inline, and selectors will
// be computed on demand when the highlighter is requested.
}
const isHighlighted =
this.ruleView.isSelectorHighlighted(computedSelector);
// Handling of click events is delegated to CssRuleView.handleEvent()
createChild(header, "button", {
class:
"ruleview-selectorhighlighter js-toggle-selector-highlighter" +
(isHighlighted ? " highlighted" : ""),
"aria-pressed": isHighlighted,
// This is used in rules.js for the selector highlighter
"data-computed-selector": computedSelector,
title: l10n("rule.selectorHighlighter.tooltip"),
});
}
this.openBrace = createChild(header, "span", {
class: "ruleview-ruleopen",
textContent: " {",
});
// We can't use a proper "ol" as it will mess with selection copy text,
// adding spaces on list item instead of the one we craft (.ruleview-rule-indent)
this.propertyList = createChild(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", () => {
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(e) {
e.preventDefault();
if (this.source.hasAttribute("unselectable")) {
return;
}
const { inspector } = this.ruleView;
if (Tools.styleEditor.isToolSupported(inspector.toolbox)) {
inspector.toolbox.viewSourceInStyleEditorByResource(
this.rule.sheet,
this.rule.ruleLine,
this.rule.ruleColumn
);
}
},
/**
* Update the text of the source link to reflect whether we're showing
* original sources or not. This is a callback for
* SourceMapURLService.subscribeByID, which see.
*
* @param {Object | null} originalLocation
* The original position object (url/line/column) or null.
*/
_updateLocation(originalLocation) {
let displayURL = this.rule.sheet?.href;
const constructed = this.rule.sheet?.constructed;
let line = this.rule.ruleLine;
if (originalLocation) {
displayURL = originalLocation.url;
line = originalLocation.line;
}
let sourceTextContent = CssLogic.shortSource({
constructed,
href: displayURL,
});
let displayLocation = displayURL ? displayURL : sourceTextContent;
if (line > 0) {
sourceTextContent += ":" + line;
displayLocation += ":" + line;
}
const title = COMPONENT_L10N.getFormatStr(
"frame.viewsourceinstyleeditor",
displayLocation
);
const sourceLabel = this.element.querySelector(
".ruleview-rule-source-label"
);
sourceLabel.setAttribute("title", title);
sourceLabel.setAttribute("href", displayURL);
sourceLabel.textContent = sourceTextContent;
},
updateSourceLink() {
if (this.rule.isSystem) {
const sourceLabel = this.element.querySelector(
".ruleview-rule-source-label"
);
const uaLabel = STYLE_INSPECTOR_L10N.getStr("rule.userAgentStyles");
sourceLabel.textContent = uaLabel + " " + this.rule.title;
sourceLabel.setAttribute("href", this.rule.sheet?.href);
} else {
this._updateLocation(null);
}
if (
this.rule.sheet &&
!this.rule.isSystem &&
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 {
this.rule.domRule.selectors.forEach((selector, i) => {
this._populateSelector(selector, i);
});
}
let focusedElSelector;
if (reset) {
// If we're going to reset the rule (i.e. if this is the `element` rule),
// we want to restore the focus after the rule is populated.
// So if this element contains the active element, retrieve its selector for later use.
if (this.element.contains(this.doc.activeElement)) {
focusedElSelector = CssLogic.findCssSelector(this.doc.activeElement);
}
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);
}
}
},
/**
* Render a given rule selector in this.selectorText element
*
* @param {String} selector: The selector text to display
* @param {Number} selectorIndex: Its index in the rule
*/
_populateSelector(selector, selectorIndex) {
if (selectorIndex !== 0) {
createChild(this.selectorText, "span", {
class: "ruleview-selector-separator",
textContent: ", ",
});
}
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.matchedSelectorIndexes.length) {
containerClass += this.rule.matchedSelectorIndexes.includes(selectorIndex)
? "matched"
: "unmatched";
}
let selectorContainerTitle;
if (
typeof this.rule.selector.selectorsSpecificity?.[selectorIndex] !==
"undefined"
) {
// The specificity that we get from the platform is a single number that we
// need to format into the common `(x,y,z)` specificity string.
const specificity =
this.rule.selector.selectorsSpecificity?.[selectorIndex];
const a = Math.floor(specificity / (1024 * 1024));
const b = Math.floor((specificity % (1024 * 1024)) / 1024);
const c = specificity % 1024;
selectorContainerTitle = STYLE_INSPECTOR_L10N.getFormatStr(
"rule.selectorSpecificity.title",
`(${a},${b},${c})`
);
}
const selectorContainer = createChild(this.selectorText, "span", {
class: containerClass,
title: selectorContainerTitle,
});
const parsedSelector = parsePseudoClassesAndAttributes(selector);
for (const selectorText of parsedSelector) {
let selectorClass = "";
switch (selectorText.type) {
case SELECTOR_ATTRIBUTE:
selectorClass = "ruleview-selector-attribute";
break;
case SELECTOR_ELEMENT:
selectorClass = "ruleview-selector-element";
break;
case SELECTOR_PSEUDO_CLASS:
selectorClass = PSEUDO_CLASSES.some(
pseudo => selectorText.value === pseudo
)
? "ruleview-selector-pseudo-class-lock"
: "ruleview-selector-pseudo-class";
break;
default:
break;
}
createChild(selectorContainer, "span", {
textContent: selectorText.value,
class: selectorClass,
});
}
const warningsContainer = this._createWarningsElementForSelector(
selectorIndex,
this.rule.domRule.selectorWarnings
);
if (warningsContainer) {
selectorContainer.append(warningsContainer);
}
},
/**
* Programatically add a new property to the rule.
*
* @param {String} name
* Property name.
* @param {String} value
* Property value.
* @param {String} priority
* Property priority.
* @param {Boolean} enabled
* True if the property should be enabled.
* @param {TextProperty} siblingProp
* Optional, property next to which the new property will be added.
* @return {TextProperty}
* The new property
*/
addProperty(name, value, priority, enabled, siblingProp) {
const prop = this.rule.createProperty(
name,
value,
priority,
enabled,
siblingProp
);
const index = this.rule.textProps.indexOf(prop);
const editor = new TextPropertyEditor(this, prop);
// 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,
// (Shift+)Tab will move the focus to the previous/next editable field
focusEditableFieldAfterApply: true,
focusEditableFieldContainerSelector: ".ruleview-rule",
destroy: this._newPropertyDestroy,
advanceChars: ":",
contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY,
popup: this.ruleView.popup,
cssProperties: this.rule.cssProperties,
inputAriaLabel: NEW_PROPERTY_NAME_INPUT_LABEL,
getCssVariables: () =>
this.rule.elementStyle.getAllCustomProperties(this.rule.pseudoElement),
});
// Auto-close the input if multiple rules get pasted into new property.
this.editor.input.addEventListener(
"paste",
blurOnMultipleProperties(this.rule.cssProperties)
);
},
/**
* Called when the new property input has been dismissed.
*
* @param {String} value
* The value in the editor.
* @param {Boolean} commit
* True if the value should be committed.
*/
_onNewProperty(value, commit) {
if (!value || !commit) {
return;
}
// parseDeclarations allows for name-less declarations, but in the present
// case, we're creating a new declaration, it doesn't make sense to accept
// these entries
this.multipleAddedProperties = parseNamedDeclarations(
this.rule.cssProperties.isKnown,
value,
true
);
// Blur the editor field now and deal with adding declarations later when
// the field gets destroyed (see _newPropertyDestroy)
this.editor.input.blur();
this.telemetry.recordEvent("edit_rule", "ruleview");
},
/**
* Called when the new property editor is destroyed.
* This is where the properties (type TextProperty) are actually being
* added, since we want to wait until after the inplace editor `destroy`
* event has been fired to keep consistent UI state.
*/
_newPropertyDestroy() {
// We're done, make the close brace 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.
*/
async _onSelectorDone(value, commit, direction) {
if (
!commit ||
this.isEditing ||
value === "" ||
value === this.rule.selectorText
) {
return;
}
const ruleView = this.ruleView;
const elementStyle = ruleView._elementStyle;
const element = elementStyle.element;
this.isEditing = true;
// Remove highlighter for the previous selector.
const computedSelector = this.rule.domRule.computedSelector;
if (this.ruleView.isSelectorHighlighted(computedSelector)) {
await this.ruleView.toggleSelectorHighlighter(
this.rule,
computedSelector
);
}
try {
const response = await this.rule.domRule.modifySelector(element, value);
// 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;