gecko-dev/devtools/client/inspector/rules/views/rule-editor.js

636 строки
22 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 {ELEMENT_STYLE} = require("devtools/shared/specs/styles");
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 promise = require("promise");
const Services = require("Services");
const EventEmitter = require("devtools/shared/event-emitter");
const {Task} = require("devtools/shared/task");
const STYLE_INSPECTOR_PROPERTIES = "devtools/shared/locales/styleinspector.properties";
const {LocalizationHelper} = require("devtools/shared/l10n");
const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES);
const PREF_ORIG_SOURCES = "devtools.styleeditor.source-maps-enabled";
/**
* 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.
*
* One step of a RuleEditor's instantiation is figuring out what's the original
* source link to the parent stylesheet (in case of source maps). This step is
* asynchronous and is triggered as soon as the RuleEditor is instantiated (see
* updateSourceLink). If you need to know when the RuleEditor is done with this,
* you need to listen to the source-link-updated event.
*
* @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.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.rule.domRule.on("location-changed", this._locationChanged);
this.toolbox.on("tool-registered", this.updateSourceLink);
this.toolbox.on("tool-unregistered", this.updateSourceLink);
this._create();
}
RuleEditor.prototype = {
destroy: function () {
this.rule.domRule.off("location-changed");
this.toolbox.off("tool-registered", this.updateSourceLink);
this.toolbox.off("tool-unregistered", this.updateSourceLink);
},
get isSelectorEditable() {
let trait = this.isEditable &&
this.ruleView.inspector.target.client.traits.selectorEditable &&
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: function () {
this.element = this.doc.createElement("div");
this.element.className = "ruleview-rule theme-separator";
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", function () {
if (this.source.hasAttribute("unselectable")) {
return;
}
let rule = this.rule.domRule;
this.ruleView.emit("ruleview-linked-clicked", rule);
}.bind(this));
let sourceLabel = this.doc.createElement("span");
sourceLabel.classList.add("ruleview-rule-source-label");
this.source.appendChild(sourceLabel);
this.updateSourceLink();
let code = createChild(this.element, "div", {
class: "ruleview-code"
});
let header = createChild(code, "div", {});
this.selectorText = createChild(header, "span", {
class: "ruleview-selectorcontainer theme-fg-color3",
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,
contextMenu: this.ruleView.inspector.onTextBoxContextMenu
});
}
if (this.rule.domRule.type !== CSSRule.KEYFRAME_RULE) {
Task.spawn(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 = yield this.rule.inherited.getUniqueSelector();
} else {
// This is an inline style from the current node.
selector = this.ruleView.inspector.selectionCssSelector;
}
let selectorHighlighter = createChild(header, "span", {
class: "ruleview-selectorhighlighter" +
(this.ruleView.highlighters.selectorHighlighterShown === selector ?
" highlighted" : ""),
title: l10n("rule.selectorHighlighter.tooltip")
});
selectorHighlighter.addEventListener("click", () => {
this.ruleView.toggleSelectorHighlighter(selectorHighlighter, selector);
});
this.uniqueSelector = selector;
this.emit("selector-icon-created");
}.bind(this));
}
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", () => {
let 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();
});
}
},
/**
* Event handler called when a property changes on the
* StyleRuleActor.
*/
_locationChanged: function () {
this.updateSourceLink();
},
updateSourceLink: function () {
let sourceLabel = this.element.querySelector(".ruleview-rule-source-label");
let title = this.rule.title;
let sourceHref = (this.rule.sheet && this.rule.sheet.href) ?
this.rule.sheet.href : title;
let sourceLine = this.rule.ruleLine > 0 ? ":" + this.rule.ruleLine : "";
sourceLabel.setAttribute("title", sourceHref + sourceLine);
if (this.toolbox.isToolRegistered("styleeditor")) {
this.source.removeAttribute("unselectable");
} else {
this.source.setAttribute("unselectable", true);
}
if (this.rule.isSystem) {
let 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", "true");
sourceLabel.textContent = uaLabel;
sourceLabel.removeAttribute("title");
}
} else {
sourceLabel.textContent = title;
if (this.rule.ruleLine === -1 && this.rule.domRule.parentStyleSheet) {
this.source.setAttribute("unselectable", "true");
}
}
let showOrig = Services.prefs.getBoolPref(PREF_ORIG_SOURCES);
if (showOrig && !this.rule.isSystem &&
this.rule.domRule.type !== ELEMENT_STYLE) {
// Only get the original source link if the right pref is set, if the rule
// isn't a system rule and if it isn't an inline rule.
this.rule.getOriginalSourceStrings().then((strings) => {
sourceLabel.textContent = strings.short;
sourceLabel.setAttribute("title", strings.full);
}, e => console.error(e)).then(() => {
this.emit("source-link-updated");
});
} else {
// If we're not getting the original source link, then we can emit the
// event immediately (but still asynchronously to give consumers a chance
// to register it after having instantiated the RuleEditor).
promise.resolve().then(() => {
this.emit("source-link-updated");
});
}
},
/**
* Update the rule editor with the contents of the rule.
*/
populate: function () {
// 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: ", "
});
}
let containerClass =
(this.rule.matchedSelectors.indexOf(selector) > -1) ?
"ruleview-selector-matched" : "ruleview-selector-unmatched";
let selectorContainer = createChild(this.selectorText, "span", {
class: containerClass
});
let parsedSelector = parsePseudoClassesAndAttributes(selector);
for (let 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 = [":active", ":focus", ":hover"].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
});
}
});
}
for (let prop of this.rule.textProps) {
if (!prop.editor && !prop.invisible) {
let editor = new TextPropertyEditor(this, prop);
this.propertyList.appendChild(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: function (name, value, priority, enabled, siblingProp) {
let prop = this.rule.createProperty(name, value, priority, enabled,
siblingProp);
let index = this.rule.textProps.indexOf(prop);
let 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: function (properties, siblingProp) {
if (!properties || !properties.length) {
return;
}
let lastProp = siblingProp;
for (let p of properties) {
let isCommented = Boolean(p.commentOffsets);
let 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: function () {
// 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,
contextMenu: this.ruleView.inspector.onTextBoxContextMenu
});
// 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: function (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();
},
/**
* 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: function () {
// 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.
*/
_onSelectorDone: Task.async(function* (value, commit, direction) {
if (!commit || this.isEditing || value === "" ||
value === this.rule.selectorText) {
return;
}
let ruleView = this.ruleView;
let elementStyle = ruleView._elementStyle;
let element = elementStyle.element;
let supportsUnmatchedRules =
this.rule.domRule.supportsModifySelectorUnmatched;
this.isEditing = true;
try {
let response = yield this.rule.domRule.modifySelector(element, value);
if (!supportsUnmatchedRules) {
this.isEditing = false;
if (response) {
this.ruleView.refreshPanel();
}
return;
}
// We recompute the list of applied styles, because editing a
// selector might cause this rule's position to change.
let applied = yield elementStyle.pageStyle.getApplied(element, {
inherited: true,
matchedSelectors: true,
filter: elementStyle.showUserAgentStyles ? "ua" : undefined
});
this.isEditing = false;
let {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;
let newRule = new Rule(elementStyle, ruleProps);
let editor = new RuleEditor(ruleView, newRule);
let rules = elementStyle.rules;
let newRuleIndex = applied.findIndex((r) => r.rule == ruleProps.rule);
let 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.markOverriddenAll();
// 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);
// Remove highlight for modified selector
if (ruleView.highlighters.selectorHighlighterShown) {
ruleView.toggleSelectorHighlighter(ruleView.lastSelectorIcon,
ruleView.highlighters.selectorHighlighterShown);
}
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: function (direction) {
if (!direction || direction === Services.focus.MOVEFOCUS_BACKWARD) {
return;
}
if (this.rule.textProps.length > 0) {
this.rule.textProps[0].editor.nameSpan.click();
} else {
this.propertyList.click();
}
}
};
module.exports = RuleEditor;