1063 lines
35 KiB
JavaScript
1063 lines
35 KiB
JavaScript
/* 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;
|