firefox-desktop/devtools/client/inspector/rules/views/rule-editor.js
2022-08-30 20:54:08 +02:00

779 lines
25 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("devtools/shared/inspector/css-logic");
const { PSEUDO_CLASSES } = require("devtools/shared/css/constants");
const {
style: { ELEMENT_STYLE },
} = require("devtools/shared/constants");
const Rule = require("devtools/client/inspector/rules/models/rule");
const {
InplaceEditor,
editableField,
editableItem,
} = require("devtools/client/shared/inplace-editor");
const TextPropertyEditor = require("devtools/client/inspector/rules/views/text-property-editor");
const {
createChild,
blurOnMultipleProperties,
promiseWarn,
} = require("devtools/client/inspector/shared/utils");
const {
parseNamedDeclarations,
parsePseudoClassesAndAttributes,
SELECTOR_ATTRIBUTE,
SELECTOR_ELEMENT,
SELECTOR_PSEUDO_CLASS,
} = require("devtools/shared/css/parsing-utils");
const Services = require("Services");
const EventEmitter = require("devtools/shared/event-emitter");
const CssLogic = require("devtools/shared/inspector/css-logic");
loader.lazyRequireGetter(this, "Tools", "devtools/client/definitions", true);
const STYLE_INSPECTOR_PROPERTIES =
"devtools/shared/locales/styleinspector.properties";
const { LocalizationHelper } = require("devtools/shared/l10n");
const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES);
/**
* 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 parts = this.rule.domRule.ancestorData.map(ancestorData => {
if (ancestorData.type == "container") {
const containerQueryParts = [
"@container",
ancestorData.containerName,
ancestorData.containerQuery,
].filter(p => !!p);
return containerQueryParts.join(" ");
}
if (ancestorData.type == "layer") {
return `@layer${ancestorData.value ? " " + ancestorData.value : ""}`;
}
if (ancestorData.type == "media") {
return `@media ${ancestorData.value}`;
}
// We shouldn't get here as `type` can only be set to "container", "layer" or "media",
// but just in case, let's return an empty string.
console.warn("Unknown ancestor data type:", ancestorData.type);
return ``;
});
// We force the string to be LTR in CSS, but as @ is listed as having neutral
// directionality and starting a string with this char would default to RTL for that
// character (when in RTL locale), and then the next char (`m` of `media`, or `l` of `layer`)
// would start a new LTR visual run, since it is strongly LTR (through `direction` CSS property).
// To have the `@` properly displayed, we force LTR with \u202A
const title = `${parts.join("\n").replaceAll("@", "\u202A@")}`;
this.ancestorDataEl = createChild(this.element, "ul", {
class: "ruleview-rule-ancestor-data theme-link",
title,
});
for (const part of parts) {
createChild(this.ancestorDataEl, "li", {
textContent: part,
});
}
}
const code = createChild(this.element, "div", {
class: "ruleview-code",
});
const header = createChild(code, "div", {});
this.selectorText = createChild(header, "span", {
class: "ruleview-selectorcontainer",
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,
});
}
if (this.rule.domRule.type !== CSSRule.KEYFRAME_RULE) {
// FIXME: Avoid having this as a nested async operation. (Bug 1664511)
(async function() {
let selector;
if (this.rule.domRule.selectors) {
// This is a "normal" rule with a selector.
selector = this.rule.domRule.selectors.join(", ");
} else if (this.rule.inherited) {
// This is an inline style from an inherited rule. Need to resolve the unique
// selector from the node which rule this is inherited from.
selector = await this.rule.inherited.getUniqueSelector();
} else {
// This is an inline style from the current node.
selector = await this.ruleView.inspector.selection.nodeFront.getUniqueSelector();
}
const isHighlighted = this.ruleView.isSelectorHighlighted(selector);
// Handling of click events is delegated to CssRuleView.handleEvent()
createChild(header, "span", {
class:
"ruleview-selectorhighlighter js-toggle-selector-highlighter" +
(isHighlighted ? " highlighted" : ""),
"data-selector": selector,
title: l10n("rule.selectorHighlighter.tooltip"),
});
}
.bind(this)()
.catch(error => {
console.error("Exception while getting unique selector", error);
}));
}
this.openBrace = createChild(header, "span", {
class: "ruleview-ruleopen",
textContent: " {",
});
this.propertyList = createChild(code, "ul", {
class: "ruleview-propertylist",
});
this.populate();
this.closeBrace = createChild(code, "div", {
class: "ruleview-ruleclose",
tabindex: this.isEditable ? "0" : "-1",
textContent: "}",
});
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();
});
}
},
/**
* 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.viewSourceInStyleEditorByFront(
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.textContent = sourceTextContent;
},
updateSourceLink() {
if (this.rule.isSystem) {
const sourceLabel = this.element.querySelector(
".ruleview-rule-source-label"
);
const title = this.rule.title;
const sourceHref = this.rule.sheet?.href || title;
const uaLabel = STYLE_INSPECTOR_L10N.getStr("rule.userAgentStyles");
sourceLabel.textContent = uaLabel + " " + title;
// Special case about:PreferenceStyleSheet, as it is generated on the
// fly and the URI is not registered with the about: handler.
// https://bugzilla.mozilla.org/show_bug.cgi?id=935803#c37
if (sourceHref === "about:PreferenceStyleSheet") {
this.source.setAttribute("unselectable", "permanent");
sourceLabel.textContent = uaLabel;
sourceLabel.removeAttribute("title");
}
} 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.sheet.actorID,
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) => {
if (i !== 0) {
createChild(this.selectorText, "span", {
class: "ruleview-selector-separator",
textContent: ", ",
});
}
const containerClass =
this.rule.matchedSelectors.indexOf(selector) > -1
? "ruleview-selector-matched"
: "ruleview-selector-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";
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,
});
}
});
}
if (reset) {
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);
}
}
},
/**
* 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, "li", {
class: "ruleview-property ruleview-newproperty",
});
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,
});
// 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", null, {
session_id: this.toolbox.sessionId,
});
},
/**
* 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.
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);
editor._moveSelectorFocus(direction);
} catch (err) {
this.isEditing = false;
promiseWarn(err);
}
},
/**
* Handle moving the focus change after a tab or return 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;