diff options
Diffstat (limited to 'devtools/client/inspector/rules/views/rule-editor.js')
-rw-r--r-- | devtools/client/inspector/rules/views/rule-editor.js | 620 |
1 files changed, 620 insertions, 0 deletions
diff --git a/devtools/client/inspector/rules/views/rule-editor.js b/devtools/client/inspector/rules/views/rule-editor.js new file mode 100644 index 000000000..2587bf19c --- /dev/null +++ b/devtools/client/inspector/rules/views/rule-editor.js @@ -0,0 +1,620 @@ +/* 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 {PREF_ORIG_SOURCES} = require("devtools/client/styleeditor/utils"); +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 { + parseDeclarations, + 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); + +/** + * 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(); + }, false); + + editableField({ + element: this.selectorText, + done: this._onSelectorDone, + cssProperties: this.rule.cssProperties, + contextMenu: this.ruleView.inspector.onTextBoxContextMenu + }); + } + + if (this.rule.domRule.type !== CSSRule.KEYFRAME_RULE) { + let selector = this.rule.domRule.selectors + ? this.rule.domRule.selectors.join(", ") + : 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.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; + }, false); + + this.element.addEventListener("mousedown", () => { + this.doc.defaultView.focus(); + }, false); + + // 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), false); + }, + + /** + * 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 = + parseDeclarations(this.rule.cssProperties.isKnown, value, true) + .filter(d => d.name); + + // 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(); + } + } +}; + +exports.RuleEditor = RuleEditor; |