diff options
Diffstat (limited to 'devtools/client/inspector/rules/views')
-rw-r--r-- | devtools/client/inspector/rules/views/moz.build | 8 | ||||
-rw-r--r-- | devtools/client/inspector/rules/views/rule-editor.js | 620 | ||||
-rw-r--r-- | devtools/client/inspector/rules/views/text-property-editor.js | 880 |
3 files changed, 1508 insertions, 0 deletions
diff --git a/devtools/client/inspector/rules/views/moz.build b/devtools/client/inspector/rules/views/moz.build new file mode 100644 index 000000000..ac0a24d76 --- /dev/null +++ b/devtools/client/inspector/rules/views/moz.build @@ -0,0 +1,8 @@ +# 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/. + +DevToolsModules( + 'rule-editor.js', + 'text-property-editor.js', +) 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; diff --git a/devtools/client/inspector/rules/views/text-property-editor.js b/devtools/client/inspector/rules/views/text-property-editor.js new file mode 100644 index 000000000..d3015f931 --- /dev/null +++ b/devtools/client/inspector/rules/views/text-property-editor.js @@ -0,0 +1,880 @@ +/* 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 {getCssProperties} = require("devtools/shared/fronts/css-properties"); +const {InplaceEditor, editableField} = + require("devtools/client/shared/inplace-editor"); +const { + createChild, + appendText, + advanceValidate, + blurOnMultipleProperties +} = require("devtools/client/inspector/shared/utils"); +const { + parseDeclarations, + parseSingleValue, +} = require("devtools/shared/css/parsing-utils"); +const Services = require("Services"); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +const SHARED_SWATCH_CLASS = "ruleview-swatch"; +const COLOR_SWATCH_CLASS = "ruleview-colorswatch"; +const BEZIER_SWATCH_CLASS = "ruleview-bezierswatch"; +const FILTER_SWATCH_CLASS = "ruleview-filterswatch"; +const ANGLE_SWATCH_CLASS = "ruleview-angleswatch"; + +/* + * An actionable element is an element which on click triggers a specific action + * (e.g. shows a color tooltip, opens a link, …). + */ +const ACTIONABLE_ELEMENTS_SELECTORS = [ + `.${COLOR_SWATCH_CLASS}`, + `.${BEZIER_SWATCH_CLASS}`, + `.${FILTER_SWATCH_CLASS}`, + `.${ANGLE_SWATCH_CLASS}`, + "a" +]; + +/** + * TextPropertyEditor is responsible for the following: + * Owns a TextProperty object. + * Manages changes to the TextProperty. + * Can be expanded to display computed properties. + * Can mark a property disabled or enabled. + * + * @param {RuleEditor} ruleEditor + * The rule editor that owns this TextPropertyEditor. + * @param {TextProperty} property + * The text property to edit. + */ +function TextPropertyEditor(ruleEditor, property) { + this.ruleEditor = ruleEditor; + this.ruleView = this.ruleEditor.ruleView; + this.doc = this.ruleEditor.doc; + this.popup = this.ruleView.popup; + this.prop = property; + this.prop.editor = this; + this.browserWindow = this.doc.defaultView.top; + this._populatedComputed = false; + this._hasPendingClick = false; + this._clickedElementOptions = null; + + const toolbox = this.ruleView.inspector.toolbox; + this.cssProperties = getCssProperties(toolbox); + + this._onEnableClicked = this._onEnableClicked.bind(this); + this._onExpandClicked = this._onExpandClicked.bind(this); + this._onStartEditing = this._onStartEditing.bind(this); + this._onNameDone = this._onNameDone.bind(this); + this._onValueDone = this._onValueDone.bind(this); + this._onSwatchCommit = this._onSwatchCommit.bind(this); + this._onSwatchPreview = this._onSwatchPreview.bind(this); + this._onSwatchRevert = this._onSwatchRevert.bind(this); + this._onValidate = this.ruleView.throttle(this._previewValue, 10, this); + this.update = this.update.bind(this); + this.updatePropertyState = this.updatePropertyState.bind(this); + + this._create(); + this.update(); +} + +TextPropertyEditor.prototype = { + /** + * Boolean indicating if the name or value is being currently edited. + */ + get editing() { + return !!(this.nameSpan.inplaceEditor || this.valueSpan.inplaceEditor || + this.ruleView.tooltips.isEditing) || this.popup.isOpen; + }, + + /** + * Get the rule to the current text property + */ + get rule() { + return this.prop.rule; + }, + + /** + * Create the property editor's DOM. + */ + _create: function () { + this.element = this.doc.createElementNS(HTML_NS, "li"); + this.element.classList.add("ruleview-property"); + this.element._textPropertyEditor = this; + + this.container = createChild(this.element, "div", { + class: "ruleview-propertycontainer" + }); + + // The enable checkbox will disable or enable the rule. + this.enable = createChild(this.container, "div", { + class: "ruleview-enableproperty theme-checkbox", + tabindex: "-1" + }); + + // Click to expand the computed properties of the text property. + this.expander = createChild(this.container, "span", { + class: "ruleview-expander theme-twisty" + }); + this.expander.addEventListener("click", this._onExpandClicked, true); + + this.nameContainer = createChild(this.container, "span", { + class: "ruleview-namecontainer" + }); + + // Property name, editable when focused. Property name + // is committed when the editor is unfocused. + this.nameSpan = createChild(this.nameContainer, "span", { + class: "ruleview-propertyname theme-fg-color5", + tabindex: this.ruleEditor.isEditable ? "0" : "-1", + }); + + appendText(this.nameContainer, ": "); + + // Create a span that will hold the property and semicolon. + // Use this span to create a slightly larger click target + // for the value. + this.valueContainer = createChild(this.container, "span", { + class: "ruleview-propertyvaluecontainer" + }); + + // Property value, editable when focused. Changes to the + // property value are applied as they are typed, and reverted + // if the user presses escape. + this.valueSpan = createChild(this.valueContainer, "span", { + class: "ruleview-propertyvalue theme-fg-color1", + tabindex: this.ruleEditor.isEditable ? "0" : "-1", + }); + + // Storing the TextProperty on the elements for easy access + // (for instance by the tooltip) + this.valueSpan.textProperty = this.prop; + this.nameSpan.textProperty = this.prop; + + // If the value is a color property we need to put it through the parser + // so that colors can be coerced into the default color type. This prevents + // us from thinking that when colors are coerced they have been changed by + // the user. + let outputParser = this.ruleView._outputParser; + let frag = outputParser.parseCssProperty(this.prop.name, this.prop.value); + let parsedValue = frag.textContent; + + // Save the initial value as the last committed value, + // for restoring after pressing escape. + this.committed = { name: this.prop.name, + value: parsedValue, + priority: this.prop.priority }; + + appendText(this.valueContainer, ";"); + + this.warning = createChild(this.container, "div", { + class: "ruleview-warning", + hidden: "", + title: l10n("rule.warning.title"), + }); + + // Filter button that filters for the current property name and is + // displayed when the property is overridden by another rule. + this.filterProperty = createChild(this.container, "div", { + class: "ruleview-overridden-rule-filter", + hidden: "", + title: l10n("rule.filterProperty.title"), + }); + + this.filterProperty.addEventListener("click", event => { + this.ruleEditor.ruleView.setFilterStyles("`" + this.prop.name + "`"); + event.stopPropagation(); + }, false); + + // Holds the viewers for the computed properties. + // will be populated in |_updateComputed|. + this.computed = createChild(this.element, "ul", { + class: "ruleview-computedlist", + }); + + // Only bind event handlers if the rule is editable. + if (this.ruleEditor.isEditable) { + this.enable.addEventListener("click", this._onEnableClicked, true); + + this.nameContainer.addEventListener("click", (event) => { + // Clicks within the name shouldn't propagate any further. + event.stopPropagation(); + + // Forward clicks on nameContainer to the editable nameSpan + if (event.target === this.nameContainer) { + this.nameSpan.click(); + } + }, false); + + editableField({ + start: this._onStartEditing, + element: this.nameSpan, + done: this._onNameDone, + destroy: this.updatePropertyState, + advanceChars: ":", + contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY, + popup: this.popup, + cssProperties: this.cssProperties, + contextMenu: this.ruleView.inspector.onTextBoxContextMenu + }); + + // Auto blur name field on multiple CSS rules get pasted in. + this.nameContainer.addEventListener("paste", + blurOnMultipleProperties(this.cssProperties), false); + + this.valueContainer.addEventListener("click", (event) => { + // Clicks within the value shouldn't propagate any further. + event.stopPropagation(); + + // Forward clicks on valueContainer to the editable valueSpan + if (event.target === this.valueContainer) { + this.valueSpan.click(); + } + }, false); + + // The mousedown event could trigger a blur event on nameContainer, which + // will trigger a call to the update function. The update function clears + // valueSpan's markup. Thus the regular click event does not bubble up, and + // listener's callbacks are not called. + // So we need to remember where the user clicks in order to re-trigger the click + // after the valueSpan's markup is re-populated. We only need to track this for + // valueSpan's child elements, because direct click on valueSpan will always + // trigger a click event. + this.valueSpan.addEventListener("mousedown", (event) => { + let clickedEl = event.target; + if (clickedEl === this.valueSpan) { + return; + } + this._hasPendingClick = true; + + let matchedSelector = ACTIONABLE_ELEMENTS_SELECTORS.find( + (selector) => clickedEl.matches(selector)); + if (matchedSelector) { + let similarElements = [...this.valueSpan.querySelectorAll(matchedSelector)]; + this._clickedElementOptions = { + selector: matchedSelector, + index: similarElements.indexOf(clickedEl) + }; + } + }, false); + + this.valueSpan.addEventListener("mouseup", (event) => { + this._clickedElementOptions = null; + this._hasPendingClick = false; + }, false); + + this.valueSpan.addEventListener("click", (event) => { + let target = event.target; + + if (target.nodeName === "a") { + event.stopPropagation(); + event.preventDefault(); + this.browserWindow.openUILinkIn(target.href, "tab"); + } + }, false); + + editableField({ + start: this._onStartEditing, + element: this.valueSpan, + done: this._onValueDone, + destroy: this.update, + validate: this._onValidate, + advanceChars: advanceValidate, + contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE, + property: this.prop, + popup: this.popup, + multiline: true, + maxWidth: () => this.container.getBoundingClientRect().width, + cssProperties: this.cssProperties, + contextMenu: this.ruleView.inspector.onTextBoxContextMenu + }); + } + }, + + /** + * Get the path from which to resolve requests for this + * rule's stylesheet. + * + * @return {String} the stylesheet's href. + */ + get sheetHref() { + let domRule = this.rule.domRule; + if (domRule) { + return domRule.href || domRule.nodeHref; + } + return undefined; + }, + + /** + * Populate the span based on changes to the TextProperty. + */ + update: function () { + if (this.ruleView.isDestroyed) { + return; + } + + this.updatePropertyState(); + + let name = this.prop.name; + this.nameSpan.textContent = name; + + // Combine the property's value and priority into one string for + // the value. + let store = this.rule.elementStyle.store; + let val = store.userProperties.getProperty(this.rule.style, name, + this.prop.value); + if (this.prop.priority) { + val += " !" + this.prop.priority; + } + + let propDirty = store.userProperties.contains(this.rule.style, name); + + if (propDirty) { + this.element.setAttribute("dirty", ""); + } else { + this.element.removeAttribute("dirty"); + } + + let outputParser = this.ruleView._outputParser; + let parserOptions = { + angleClass: "ruleview-angle", + angleSwatchClass: SHARED_SWATCH_CLASS + " " + ANGLE_SWATCH_CLASS, + bezierClass: "ruleview-bezier", + bezierSwatchClass: SHARED_SWATCH_CLASS + " " + BEZIER_SWATCH_CLASS, + colorClass: "ruleview-color", + colorSwatchClass: SHARED_SWATCH_CLASS + " " + COLOR_SWATCH_CLASS, + filterClass: "ruleview-filter", + filterSwatchClass: SHARED_SWATCH_CLASS + " " + FILTER_SWATCH_CLASS, + gridClass: "ruleview-grid", + defaultColorType: !propDirty, + urlClass: "theme-link", + baseURI: this.sheetHref + }; + let frag = outputParser.parseCssProperty(name, val, parserOptions); + this.valueSpan.innerHTML = ""; + this.valueSpan.appendChild(frag); + + this.ruleView.emit("property-value-updated", this.valueSpan); + + // Attach the color picker tooltip to the color swatches + this._colorSwatchSpans = + this.valueSpan.querySelectorAll("." + COLOR_SWATCH_CLASS); + if (this.ruleEditor.isEditable) { + for (let span of this._colorSwatchSpans) { + // Adding this swatch to the list of swatches our colorpicker + // knows about + this.ruleView.tooltips.colorPicker.addSwatch(span, { + onShow: this._onStartEditing, + onPreview: this._onSwatchPreview, + onCommit: this._onSwatchCommit, + onRevert: this._onSwatchRevert + }); + span.on("unit-change", this._onSwatchCommit); + let title = l10n("rule.colorSwatch.tooltip"); + span.setAttribute("title", title); + } + } + + // Attach the cubic-bezier tooltip to the bezier swatches + this._bezierSwatchSpans = + this.valueSpan.querySelectorAll("." + BEZIER_SWATCH_CLASS); + if (this.ruleEditor.isEditable) { + for (let span of this._bezierSwatchSpans) { + // Adding this swatch to the list of swatches our colorpicker + // knows about + this.ruleView.tooltips.cubicBezier.addSwatch(span, { + onShow: this._onStartEditing, + onPreview: this._onSwatchPreview, + onCommit: this._onSwatchCommit, + onRevert: this._onSwatchRevert + }); + let title = l10n("rule.bezierSwatch.tooltip"); + span.setAttribute("title", title); + } + } + + // Attach the filter editor tooltip to the filter swatch + let span = this.valueSpan.querySelector("." + FILTER_SWATCH_CLASS); + if (this.ruleEditor.isEditable) { + if (span) { + parserOptions.filterSwatch = true; + + this.ruleView.tooltips.filterEditor.addSwatch(span, { + onShow: this._onStartEditing, + onPreview: this._onSwatchPreview, + onCommit: this._onSwatchCommit, + onRevert: this._onSwatchRevert + }, outputParser, parserOptions); + let title = l10n("rule.filterSwatch.tooltip"); + span.setAttribute("title", title); + } + } + + this.angleSwatchSpans = + this.valueSpan.querySelectorAll("." + ANGLE_SWATCH_CLASS); + if (this.ruleEditor.isEditable) { + for (let angleSpan of this.angleSwatchSpans) { + angleSpan.on("unit-change", this._onSwatchCommit); + let title = l10n("rule.angleSwatch.tooltip"); + angleSpan.setAttribute("title", title); + } + } + + let gridToggle = this.valueSpan.querySelector(".ruleview-grid"); + if (gridToggle) { + gridToggle.setAttribute("title", l10n("rule.gridToggle.tooltip")); + if (this.ruleView.highlighters.gridHighlighterShown === + this.ruleView.inspector.selection.nodeFront) { + gridToggle.classList.add("active"); + } + } + + // Now that we have updated the property's value, we might have a pending + // click on the value container. If we do, we have to trigger a click event + // on the right element. + if (this._hasPendingClick) { + this._hasPendingClick = false; + let elToClick; + + if (this._clickedElementOptions !== null) { + let {selector, index} = this._clickedElementOptions; + elToClick = this.valueSpan.querySelectorAll(selector)[index]; + + this._clickedElementOptions = null; + } + + if (!elToClick) { + elToClick = this.valueSpan; + } + elToClick.click(); + } + + // Populate the computed styles. + this._updateComputed(); + + // Update the rule property highlight. + this.ruleView._updatePropertyHighlight(this); + }, + + _onStartEditing: function () { + this.element.classList.remove("ruleview-overridden"); + this.filterProperty.hidden = true; + this.enable.style.visibility = "hidden"; + }, + + /** + * Update the visibility of the enable checkbox, the warning indicator and + * the filter property, as well as the overriden state of the property. + */ + updatePropertyState: function () { + if (this.prop.enabled) { + this.enable.style.removeProperty("visibility"); + this.enable.setAttribute("checked", ""); + } else { + this.enable.style.visibility = "visible"; + this.enable.removeAttribute("checked"); + } + + this.warning.hidden = this.editing || this.isValid(); + this.filterProperty.hidden = this.editing || + !this.isValid() || + !this.prop.overridden || + this.ruleEditor.rule.isUnmatched; + + if (!this.editing && + (this.prop.overridden || !this.prop.enabled || + !this.prop.isKnownProperty())) { + this.element.classList.add("ruleview-overridden"); + } else { + this.element.classList.remove("ruleview-overridden"); + } + }, + + /** + * Update the indicator for computed styles. The computed styles themselves + * are populated on demand, when they become visible. + */ + _updateComputed: function () { + this.computed.innerHTML = ""; + + let showExpander = this.prop.computed.some(c => c.name !== this.prop.name); + this.expander.style.visibility = showExpander ? "visible" : "hidden"; + + this._populatedComputed = false; + if (this.expander.hasAttribute("open")) { + this._populateComputed(); + } + }, + + /** + * Populate the list of computed styles. + */ + _populateComputed: function () { + if (this._populatedComputed) { + return; + } + this._populatedComputed = true; + + for (let computed of this.prop.computed) { + // Don't bother to duplicate information already + // shown in the text property. + if (computed.name === this.prop.name) { + continue; + } + + let li = createChild(this.computed, "li", { + class: "ruleview-computed" + }); + + if (computed.overridden) { + li.classList.add("ruleview-overridden"); + } + + createChild(li, "span", { + class: "ruleview-propertyname theme-fg-color5", + textContent: computed.name + }); + appendText(li, ": "); + + let outputParser = this.ruleView._outputParser; + let frag = outputParser.parseCssProperty( + computed.name, computed.value, { + colorSwatchClass: "ruleview-swatch ruleview-colorswatch", + urlClass: "theme-link", + baseURI: this.sheetHref + } + ); + + // Store the computed property value that was parsed for output + computed.parsedValue = frag.textContent; + + createChild(li, "span", { + class: "ruleview-propertyvalue theme-fg-color1", + child: frag + }); + + appendText(li, ";"); + + // Store the computed style element for easy access when highlighting + // styles + computed.element = li; + } + }, + + /** + * Handles clicks on the disabled property. + */ + _onEnableClicked: function (event) { + let checked = this.enable.hasAttribute("checked"); + if (checked) { + this.enable.removeAttribute("checked"); + } else { + this.enable.setAttribute("checked", ""); + } + this.prop.setEnabled(!checked); + event.stopPropagation(); + }, + + /** + * Handles clicks on the computed property expander. If the computed list is + * open due to user expanding or style filtering, collapse the computed list + * and close the expander. Otherwise, add user-open attribute which is used to + * expand the computed list and tracks whether or not the computed list is + * expanded by manually by the user. + */ + _onExpandClicked: function (event) { + if (this.computed.hasAttribute("filter-open") || + this.computed.hasAttribute("user-open")) { + this.expander.removeAttribute("open"); + this.computed.removeAttribute("filter-open"); + this.computed.removeAttribute("user-open"); + } else { + this.expander.setAttribute("open", "true"); + this.computed.setAttribute("user-open", ""); + this._populateComputed(); + } + + event.stopPropagation(); + }, + + /** + * Expands the computed list when a computed property is matched by the style + * filtering. The filter-open attribute is used to track whether or not the + * computed list was toggled opened by the filter. + */ + expandForFilter: function () { + if (!this.computed.hasAttribute("user-open")) { + this.expander.setAttribute("open", "true"); + this.computed.setAttribute("filter-open", ""); + this._populateComputed(); + } + }, + + /** + * Collapses the computed list that was expanded by style filtering. + */ + collapseForFilter: function () { + this.computed.removeAttribute("filter-open"); + + if (!this.computed.hasAttribute("user-open")) { + this.expander.removeAttribute("open"); + } + }, + + /** + * Called when the property name'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. + */ + _onNameDone: function (value, commit, direction) { + let isNameUnchanged = (!commit && !this.ruleEditor.isEditing) || + this.committed.name === value; + if (this.prop.value && isNameUnchanged) { + return; + } + + // Remove a property if the name is empty + if (!value.trim()) { + this.remove(direction); + return; + } + + // Remove a property if the property value is empty and the property + // value is not about to be focused + if (!this.prop.value && + direction !== Services.focus.MOVEFOCUS_FORWARD) { + this.remove(direction); + return; + } + + // Adding multiple rules inside of name field overwrites the current + // property with the first, then adds any more onto the property list. + let properties = parseDeclarations(this.cssProperties.isKnown, value); + + if (properties.length) { + this.prop.setName(properties[0].name); + this.committed.name = this.prop.name; + + if (!this.prop.enabled) { + this.prop.setEnabled(true); + } + + if (properties.length > 1) { + this.prop.setValue(properties[0].value, properties[0].priority); + this.ruleEditor.addProperties(properties.slice(1), this.prop); + } + } + }, + + /** + * Remove property from style and the editors from DOM. + * Begin editing next or previous available property given the focus + * direction. + * + * @param {Number} direction + * The move focus direction number. + */ + remove: function (direction) { + if (this._colorSwatchSpans && this._colorSwatchSpans.length) { + for (let span of this._colorSwatchSpans) { + this.ruleView.tooltips.colorPicker.removeSwatch(span); + span.off("unit-change", this._onSwatchCommit); + } + } + + if (this.angleSwatchSpans && this.angleSwatchSpans.length) { + for (let span of this.angleSwatchSpans) { + span.off("unit-change", this._onSwatchCommit); + } + } + + this.element.parentNode.removeChild(this.element); + this.ruleEditor.rule.editClosestTextProperty(this.prop, direction); + this.nameSpan.textProperty = null; + this.valueSpan.textProperty = null; + this.prop.remove(); + }, + + /** + * Called when a value editor closes. If the user pressed escape, + * revert to the value this property had before editing. + * + * @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. + */ + _onValueDone: function (value = "", commit, direction) { + let parsedProperties = this._getValueAndExtraProperties(value); + let val = parseSingleValue(this.cssProperties.isKnown, + parsedProperties.firstValue); + let isValueUnchanged = (!commit && !this.ruleEditor.isEditing) || + !parsedProperties.propertiesToAdd.length && + this.committed.value === val.value && + this.committed.priority === val.priority; + // If the value is not empty and unchanged, revert the property back to + // its original value and enabled or disabled state + if (value.trim() && isValueUnchanged) { + this.ruleEditor.rule.previewPropertyValue(this.prop, val.value, + val.priority); + this.rule.setPropertyEnabled(this.prop, this.prop.enabled); + return; + } + + if (this.isDisplayGrid()) { + this.ruleView.highlighters._hideGridHighlighter(); + } + + // First, set this property value (common case, only modified a property) + this.prop.setValue(val.value, val.priority); + + if (!this.prop.enabled) { + this.prop.setEnabled(true); + } + + this.committed.value = this.prop.value; + this.committed.priority = this.prop.priority; + + // If needed, add any new properties after this.prop. + this.ruleEditor.addProperties(parsedProperties.propertiesToAdd, this.prop); + + // If the input value is empty and the focus is moving forward to the next + // editable field, then remove the whole property. + // A timeout is used here to accurately check the state, since the inplace + // editor `done` and `destroy` events fire before the next editor + // is focused. + if (!value.trim() && direction !== Services.focus.MOVEFOCUS_BACKWARD) { + setTimeout(() => { + if (!this.editing) { + this.remove(direction); + } + }, 0); + } + }, + + /** + * Called when the swatch editor wants to commit a value change. + */ + _onSwatchCommit: function () { + this._onValueDone(this.valueSpan.textContent, true); + this.update(); + }, + + /** + * Called when the swatch editor wants to preview a value change. + */ + _onSwatchPreview: function () { + this._previewValue(this.valueSpan.textContent); + }, + + /** + * Called when the swatch editor closes from an ESC. Revert to the original + * value of this property before editing. + */ + _onSwatchRevert: function () { + this._previewValue(this.prop.value, true); + this.update(); + }, + + /** + * Parse a value string and break it into pieces, starting with the + * first value, and into an array of additional properties (if any). + * + * Example: Calling with "red; width: 100px" would return + * { firstValue: "red", propertiesToAdd: [{ name: "width", value: "100px" }] } + * + * @param {String} value + * The string to parse + * @return {Object} An object with the following properties: + * firstValue: A string containing a simple value, like + * "red" or "100px!important" + * propertiesToAdd: An array with additional properties, following the + * parseDeclarations format of {name,value,priority} + */ + _getValueAndExtraProperties: function (value) { + // The inplace editor will prevent manual typing of multiple properties, + // but we need to deal with the case during a paste event. + // Adding multiple properties inside of value editor sets value with the + // first, then adds any more onto the property list (below this property). + let firstValue = value; + let propertiesToAdd = []; + + let properties = parseDeclarations(this.cssProperties.isKnown, value); + + // Check to see if the input string can be parsed as multiple properties + if (properties.length) { + // Get the first property value (if any), and any remaining + // properties (if any) + if (!properties[0].name && properties[0].value) { + firstValue = properties[0].value; + propertiesToAdd = properties.slice(1); + } else if (properties[0].name && properties[0].value) { + // In some cases, the value could be a property:value pair + // itself. Join them as one value string and append + // potentially following properties + firstValue = properties[0].name + ": " + properties[0].value; + propertiesToAdd = properties.slice(1); + } + } + + return { + propertiesToAdd: propertiesToAdd, + firstValue: firstValue + }; + }, + + /** + * Live preview this property, without committing changes. + * + * @param {String} value + * The value to set the current property to. + * @param {Boolean} reverting + * True if we're reverting the previously previewed value + */ + _previewValue: function (value, reverting = false) { + // Since function call is throttled, we need to make sure we are still + // editing, and any selector modifications have been completed + if (!reverting && (!this.editing || this.ruleEditor.isEditing)) { + return; + } + + let val = parseSingleValue(this.cssProperties.isKnown, value); + this.ruleEditor.rule.previewPropertyValue(this.prop, val.value, + val.priority); + }, + + /** + * Validate this property. Does it make sense for this value to be assigned + * to this property name? This does not apply the property value + * + * @return {Boolean} true if the property value is valid, false otherwise. + */ + isValid: function () { + return this.prop.isValid(); + }, + + /** + * Returns true if the property is a `display: grid` declaration. + * + * @return {Boolean} true if the property is a `display: grid` declaration. + */ + isDisplayGrid: function () { + return this.prop.name === "display" && this.prop.value === "grid"; + } +}; + +exports.TextPropertyEditor = TextPropertyEditor; |