1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
2 /* vim: set ts=2 et sw=2 tw=80: */
3 /* This Source Code Form is subject to the terms of the Mozilla Public
4 * License, v. 2.0. If a copy of the MPL was not distributed with this
5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
9 const promise = require("promise");
10 const CssLogic = require("devtools/shared/inspector/css-logic");
11 const {ELEMENT_STYLE} = require("devtools/shared/specs/styles");
12 const TextProperty = require("devtools/client/inspector/rules/models/text-property");
13 const Services = require("Services");
15 loader.lazyRequireGetter(this, "updateSourceLink", "devtools/client/inspector/rules/actions/rules", true);
16 loader.lazyRequireGetter(this, "promiseWarn", "devtools/client/inspector/shared/utils", true);
17 loader.lazyRequireGetter(this, "parseNamedDeclarations", "devtools/shared/css/parsing-utils", true);
19 const STYLE_INSPECTOR_PROPERTIES = "devtools/shared/locales/styleinspector.properties";
20 const {LocalizationHelper} = require("devtools/shared/l10n");
21 const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES);
24 * Rule is responsible for the following:
25 * Manages a single style declaration or rule.
26 * Applies changes to the properties in a rule.
27 * Maintains a list of TextProperty objects.
31 * @param {ElementStyle} elementStyle
32 * The ElementStyle to which this rule belongs.
33 * @param {Object} options
34 * The information used to construct this rule. Properties include:
35 * rule: A StyleRuleActor
36 * inherited: An element this rule was inherited from. If omitted,
37 * the rule applies directly to the current element.
38 * isSystem: Is this a user agent style?
39 * isUnmatched: True if the rule does not match the current selected
40 * element, otherwise, false.
42 constructor(elementStyle, options) {
43 this.elementStyle = elementStyle;
44 this.domRule = options.rule;
45 this.matchedSelectors = options.matchedSelectors || [];
46 this.pseudoElement = options.pseudoElement || "";
47 this.isSystem = options.isSystem;
48 this.isUnmatched = options.isUnmatched || false;
49 this.inherited = options.inherited || null;
50 this.keyframes = options.keyframes || null;
52 this.mediaText = this.domRule && this.domRule.mediaText ? this.domRule.mediaText : "";
53 this.cssProperties = this.elementStyle.ruleView.cssProperties;
54 this.inspector = this.elementStyle.ruleView.inspector;
55 this.store = this.elementStyle.ruleView.store;
57 // Populate the text properties with the style's current authoredText
58 // value, and add in any disabled properties from the store.
59 this.textProps = this._getTextProperties();
60 this.textProps = this.textProps.concat(this._getDisabledProperties());
62 this.getUniqueSelector = this.getUniqueSelector.bind(this);
63 this.onLocationChanged = this.onLocationChanged.bind(this);
64 this.updateSourceLocation = this.updateSourceLocation.bind(this);
68 if (this.unsubscribeSourceMap) {
69 this.unsubscribeSourceMap();
72 this.domRule.off("location-changed", this.onLocationChanged);
76 return this.textProps;
80 if (!this.inherited) {
85 inherited: this.inherited,
86 inheritedSource: this.inheritedSource,
92 getUniqueSelector: this.getUniqueSelector,
93 matchedSelectors: this.matchedSelectors,
94 selectors: this.domRule.selectors,
95 selectorText: this.keyframes ? this.domRule.keyText : this.selectorText,
101 label: this.getSourceText(CssLogic.shortSource({ href: this.sourceLocation.url })),
102 title: this.getSourceText(this.sourceLocation.url),
106 get sourceMapURLService() {
107 return this.inspector.toolbox.sourceMapURLService;
111 * Returns the original source location which includes the original URL, line and
114 get sourceLocation() {
115 if (!this._sourceLocation) {
116 this._sourceLocation = {
117 column: this.ruleColumn,
119 url: this.sheet ? this.sheet.href || this.sheet.nodeHref : null,
123 return this._sourceLocation;
127 let title = CssLogic.shortSource(this.sheet);
128 if (this.domRule.type !== ELEMENT_STYLE && this.ruleLine > 0) {
129 title += ":" + this.ruleLine;
132 return title + (this.mediaText ? " @media " + this.mediaText : "");
135 get inheritedSource() {
136 if (this._inheritedSource) {
137 return this._inheritedSource;
139 this._inheritedSource = "";
140 if (this.inherited) {
141 let eltText = this.inherited.displayName;
142 if (this.inherited.id) {
143 eltText += "#" + this.inherited.id;
145 this._inheritedSource =
146 STYLE_INSPECTOR_L10N.getFormatStr("rule.inheritedFrom", eltText);
148 return this._inheritedSource;
151 get keyframesName() {
152 if (this._keyframesName) {
153 return this._keyframesName;
155 this._keyframesName = "";
156 if (this.keyframes) {
157 this._keyframesName =
158 STYLE_INSPECTOR_L10N.getFormatStr("rule.keyframe", this.keyframes.name);
160 return this._keyframesName;
163 get keyframesRule() {
164 if (!this.keyframes) {
169 id: this.keyframes.actorID,
170 keyframesName: this.keyframesName,
175 return this.domRule.selectors ? this.domRule.selectors.join(", ") :
176 CssLogic.l10n("rule.sourceElement");
180 * The rule's stylesheet.
183 return this.domRule ? this.domRule.parentStyleSheet : null;
187 * The rule's line within a stylesheet
190 return this.domRule ? this.domRule.line : -1;
194 * The rule's column within a stylesheet
197 return this.domRule ? this.domRule.column : null;
201 * Returns the TextProperty with the given id or undefined if it cannot be found.
205 * @return {TextProperty|undefined} with the given id in the current Rule or undefined
206 * if it cannot be found.
209 return this.textProps.find(textProp => textProp.id === id);
213 * Returns a formatted source text of the given stylesheet URL with its source line
216 * @param {String} url
217 * The stylesheet URL.
221 return `${STYLE_INSPECTOR_L10N.getStr("rule.userAgentStyles")} ${this.title}`;
224 let sourceText = url;
226 if (this.sourceLocation.line > 0) {
227 sourceText += ":" + this.sourceLocation.line;
230 if (this.mediaText) {
231 sourceText += " @media " + this.mediaText;
238 * Returns an unique selector for the CSS rule.
240 async getUniqueSelector() {
243 if (this.domRule.selectors) {
244 // This is a style rule with a selector.
245 selector = this.domRule.selectors.join(", ");
246 } else if (this.inherited) {
247 // This is an inline style from an inherited rule. Need to resolve the unique
248 // selector from the node which rule this is inherited from.
249 selector = await this.inherited.getUniqueSelector();
251 // This is an inline style from the current node.
252 selector = this.inspector.selectionCssSelector;
259 * Returns true if the rule matches the creation options
262 * @param {Object} options
263 * Creation options. See the Rule constructor for documentation.
266 return this.domRule === options.rule;
270 * Create a new TextProperty to include in the rule.
272 * @param {String} name
273 * The text property name (such as "background" or "border-top").
274 * @param {String} value
275 * The property's value (not including priority).
276 * @param {String} priority
277 * The property's priority (either "important" or an empty string).
278 * @param {Boolean} enabled
279 * True if the property should be enabled.
280 * @param {TextProperty} siblingProp
281 * Optional, property next to which the new property will be added.
283 createProperty(name, value, priority, enabled, siblingProp) {
284 const prop = new TextProperty(this, name, value, priority, enabled);
288 ind = this.textProps.indexOf(siblingProp) + 1;
289 this.textProps.splice(ind, 0, prop);
291 ind = this.textProps.length;
292 this.textProps.push(prop);
295 this.applyProperties((modifications) => {
296 modifications.createProperty(ind, name, value, priority, enabled);
297 // Now that the rule has been updated, the server might have given us data
298 // that changes the state of the property. Update it now.
306 * Helper function for applyProperties that is called when the actor
307 * does not support as-authored styles. Store disabled properties
308 * in the element style's store.
310 _applyPropertiesNoAuthored(modifications) {
311 this.elementStyle.markOverriddenAll();
313 const disabledProps = [];
315 for (const prop of this.textProps) {
316 if (prop.invisible) {
323 priority: prop.priority,
327 if (prop.value.trim() === "") {
331 modifications.setProperty(-1, prop.name, prop.value, prop.priority);
333 prop.updateComputed();
336 // Store disabled properties in the disabled store.
337 const disabled = this.elementStyle.store.disabled;
338 if (disabledProps.length > 0) {
339 disabled.set(this.domRule, disabledProps);
341 disabled.delete(this.domRule);
344 return modifications.apply().then(() => {
346 // Note that even though StyleRuleActors normally provide parsed
347 // declarations already, _applyPropertiesNoAuthored is only used when
348 // connected to older backend that do not provide them. So parse here.
349 for (const cssProp of parseNamedDeclarations(this.cssProperties.isKnown,
350 this.domRule.authoredText)) {
351 cssProps[cssProp.name] = cssProp;
354 for (const textProp of this.textProps) {
355 if (!textProp.enabled) {
358 let cssProp = cssProps[textProp.name];
368 textProp.priority = cssProp.priority;
374 * A helper for applyProperties that applies properties in the "as
375 * authored" case; that is, when the StyleRuleActor supports
378 _applyPropertiesAuthored(modifications) {
379 return modifications.apply().then(() => {
380 // The rewriting may have required some other property values to
381 // change, e.g., to insert some needed terminators. Update the
382 // relevant properties here.
383 for (const index in modifications.changedDeclarations) {
384 const newValue = modifications.changedDeclarations[index];
385 this.textProps[index].updateValue(newValue);
387 // Recompute and redisplay the computed properties.
388 for (const prop of this.textProps) {
389 if (!prop.invisible && prop.enabled) {
390 prop.updateComputed();
398 * Reapply all the properties in this rule, and update their
399 * computed styles. Will re-mark overridden properties. Sets the
400 * |_applyingModifications| property to a promise which will resolve
401 * when the edit has completed.
403 * @param {Function} modifier a function that takes a RuleModificationList
404 * (or RuleRewriter) as an argument and that modifies it
405 * to apply the desired edit
406 * @return {Promise} a promise which will resolve when the edit
409 applyProperties(modifier) {
410 // If there is already a pending modification, we have to wait
411 // until it settles before applying the next modification.
412 const resultPromise =
413 promise.resolve(this._applyingModifications).then(() => {
414 const modifications = this.domRule.startModifyingProperties(
416 modifier(modifications);
417 if (this.domRule.canSetRuleText) {
418 return this._applyPropertiesAuthored(modifications);
420 return this._applyPropertiesNoAuthored(modifications);
422 this.elementStyle.markOverriddenAll();
424 if (resultPromise === this._applyingModifications) {
425 this._applyingModifications = null;
426 this.elementStyle._changed();
428 }).catch(promiseWarn);
430 this._applyingModifications = resultPromise;
431 return resultPromise;
435 * Renames a property.
437 * @param {TextProperty} property
438 * The property to rename.
439 * @param {String} name
440 * The new property name (such as "background" or "border-top").
443 setPropertyName(property, name) {
444 if (name === property.name) {
445 return Promise.resolve();
448 const oldName = property.name;
449 property.name = name;
450 const index = this.textProps.indexOf(property);
451 return this.applyProperties(modifications => {
452 modifications.renameProperty(index, oldName, name);
457 * Sets the value and priority of a property, then reapply all properties.
459 * @param {TextProperty} property
460 * The property to manipulate.
461 * @param {String} value
462 * The property's value (not including priority).
463 * @param {String} priority
464 * The property's priority (either "important" or an empty string).
467 setPropertyValue(property, value, priority) {
468 if (value === property.value && priority === property.priority) {
469 return Promise.resolve();
472 property.value = value;
473 property.priority = priority;
475 const index = this.textProps.indexOf(property);
476 return this.applyProperties(modifications => {
477 modifications.setProperty(index, property.name, value, priority);
482 * Just sets the value and priority of a property, in order to preview its
483 * effect on the content document.
485 * @param {TextProperty} property
486 * The property which value will be previewed
487 * @param {String} value
488 * The value to be used for the preview
489 * @param {String} priority
490 * The property's priority (either "important" or an empty string).
493 previewPropertyValue(property, value, priority) {
494 const modifications = this.domRule.startModifyingProperties(this.cssProperties);
495 modifications.setProperty(this.textProps.indexOf(property),
496 property.name, value, priority);
497 return modifications.apply().then(() => {
498 // Ensure dispatching a ruleview-changed event
500 this.elementStyle._changed();
505 * Disables or enables given TextProperty.
507 * @param {TextProperty} property
508 * The property to enable/disable
509 * @param {Boolean} value
511 setPropertyEnabled(property, value) {
512 if (property.enabled === !!value) {
515 property.enabled = !!value;
516 const index = this.textProps.indexOf(property);
517 this.applyProperties((modifications) => {
518 modifications.setPropertyEnabled(index, property.name, property.enabled);
523 * Remove a given TextProperty from the rule and update the rule
526 * @param {TextProperty} property
527 * The property to be removed
529 removeProperty(property) {
530 const index = this.textProps.indexOf(property);
531 this.textProps.splice(index, 1);
532 // Need to re-apply properties in case removing this TextProperty
533 // exposes another one.
534 this.applyProperties((modifications) => {
535 modifications.removeProperty(index, property.name);
540 * Get the list of TextProperties from the style. Needs
541 * to parse the style's authoredText.
543 _getTextProperties() {
544 const textProps = [];
545 const store = this.elementStyle.store;
547 // Starting with FF49, StyleRuleActors provide parsed declarations.
548 let props = this.domRule.declarations;
550 // If the authored text has an invalid property, it will show up
551 // as nameless. Skip these as we don't currently have a good
552 // way to display them.
553 props = parseNamedDeclarations(this.cssProperties.isKnown,
554 this.domRule.authoredText, true);
557 for (const prop of props) {
558 const name = prop.name;
559 // In an inherited rule, we only show inherited properties.
560 // However, we must keep all properties in order for rule
561 // rewriting to work properly. So, compute the "invisible"
563 const invisible = this.inherited && !this.cssProperties.isInherited(name);
564 const value = store.userProperties.getProperty(this.domRule, name,
566 const textProp = new TextProperty(this, name, value, prop.priority,
567 !("commentOffsets" in prop),
569 textProps.push(textProp);
576 * Return the list of disabled properties from the store for this rule.
578 _getDisabledProperties() {
579 const store = this.elementStyle.store;
581 // Include properties from the disabled property store, if any.
582 const disabledProps = store.disabled.get(this.domRule);
583 if (!disabledProps) {
587 const textProps = [];
589 for (const prop of disabledProps) {
590 const value = store.userProperties.getProperty(this.domRule, prop.name,
592 const textProp = new TextProperty(this, prop.name, value, prop.priority);
593 textProp.enabled = false;
594 textProps.push(textProp);
601 * Reread the current state of the rules and rebuild text
602 * properties as needed.
605 this.matchedSelectors = options.matchedSelectors || [];
606 const newTextProps = this._getTextProperties();
608 // The element style rule behaves differently on refresh. We basically need to update
609 // it to reflect the new text properties exactly. The order might have changed, some
610 // properties might have been removed, etc. And we don't need to mark anything as
611 // disabled here. The element style rule should always reflect the content of the
613 if (this.domRule.type === ELEMENT_STYLE) {
614 this.textProps = newTextProps;
617 this.editor.populate(true);
623 // Update current properties for each property present on the style.
624 // This will mark any touched properties with _visited so we
625 // can detect properties that weren't touched (because they were
626 // removed from the style).
627 // Also keep track of properties that didn't exist in the current set
629 const brandNewProps = [];
630 for (const newProp of newTextProps) {
631 if (!this._updateTextProperty(newProp)) {
632 brandNewProps.push(newProp);
636 // Refresh editors and disabled state for all the properties that
638 for (const prop of this.textProps) {
639 // Properties that weren't touched during the update
640 // process must no longer exist on the node. Mark them disabled.
641 if (!prop._visited) {
642 prop.enabled = false;
645 delete prop._visited;
649 // Add brand new properties.
650 this.textProps = this.textProps.concat(brandNewProps);
652 // Refresh the editor if one already exists.
654 this.editor.populate();
659 * Update the current TextProperties that match a given property
660 * from the authoredText. Will choose one existing TextProperty to update
661 * with the new property's value, and will disable all others.
663 * When choosing the best match to reuse, properties will be chosen
664 * by assigning a rank and choosing the highest-ranked property:
665 * Name, value, and priority match, enabled. (6)
666 * Name, value, and priority match, disabled. (5)
667 * Name and value match, enabled. (4)
668 * Name and value match, disabled. (3)
669 * Name matches, enabled. (2)
670 * Name matches, disabled. (1)
672 * If no existing properties match the property, nothing happens.
674 * @param {TextProperty} newProp
675 * The current version of the property, as parsed from the
676 * authoredText in Rule._getTextProperties().
677 * @return {Boolean} true if a property was updated, false if no properties
680 _updateTextProperty(newProp) {
681 const match = { rank: 0, prop: null };
683 for (const prop of this.textProps) {
684 if (prop.name !== newProp.name) {
688 // Mark this property visited.
689 prop._visited = true;
691 // Start at rank 1 for matching name.
694 // Value and Priority matches add 2 to the rank.
695 // Being enabled adds 1. This ranks better matches higher,
696 // with priority breaking ties.
697 if (prop.value === newProp.value) {
699 if (prop.priority === newProp.priority) {
708 if (rank > match.rank) {
710 // We outrank a previous match, disable it.
711 match.prop.enabled = false;
712 match.prop.updateEditor();
717 // A previous match outranks us, disable ourself.
718 prop.enabled = false;
723 // If we found a match, update its value with the new text property
726 match.prop.set(newProp);
734 * Jump between editable properties in the UI. If the focus direction is
735 * forward, begin editing the next property name if available or focus the
736 * new property editor otherwise. If the focus direction is backward,
737 * begin editing the previous property value or focus the selector editor if
738 * this is the first element in the property list.
740 * @param {TextProperty} textProperty
741 * The text property that will be left to focus on a sibling.
742 * @param {Number} direction
743 * The move focus direction number.
745 editClosestTextProperty(textProperty, direction) {
746 let index = this.textProps.indexOf(textProperty);
748 if (direction === Services.focus.MOVEFOCUS_FORWARD) {
749 for (++index; index < this.textProps.length; ++index) {
750 if (!this.textProps[index].invisible) {
754 if (index === this.textProps.length) {
755 textProperty.rule.editor.closeBrace.click();
757 this.textProps[index].editor.nameSpan.click();
759 } else if (direction === Services.focus.MOVEFOCUS_BACKWARD) {
760 for (--index; index >= 0; --index) {
761 if (!this.textProps[index].invisible) {
766 textProperty.editor.ruleEditor.selectorText.click();
768 this.textProps[index].editor.valueSpan.click();
774 * Return a string representation of the rule.
777 const selectorText = this.selectorText;
779 const terminator = Services.appinfo.OS === "WINNT" ? "\r\n" : "\n";
781 for (const textProp of this.textProps) {
782 if (!textProp.invisible) {
783 cssText += "\t" + textProp.stringifyProperty() + terminator;
787 return selectorText + " {" + terminator + cssText + "}";
791 * See whether this rule has any non-invisible properties.
792 * @return {Boolean} true if there is any visible property, or false
793 * if all properties are invisible
795 hasAnyVisibleProperties() {
796 for (const prop of this.textProps) {
797 if (!prop.invisible) {
805 * Handler for "location-changed" events fired from the StyleRuleActor. This could
806 * occur by adding a new declaration to the rule. Updates the source location of the
807 * rule. This will overwrite the source map location.
809 onLocationChanged() {
810 const url = this.sheet ? this.sheet.href || this.sheet.nodeHref : null;
811 this.updateSourceLocation(url, this.ruleLine, this.ruleColumn);
815 * Subscribes the rule to the source map service to map the the original source
818 subscribeToLocationChange() {
819 const { url, line, column } = this.sourceLocation;
821 if (url && !this.isSystem && this.domRule.type !== ELEMENT_STYLE) {
822 // Subscribe returns an unsubscribe function that can be called on destroy.
823 this.unsubscribeSourceMap = this.sourceMapURLService.subscribe(url, line, column,
824 (enabled, sourceUrl, sourceLine, sourceColumn) => {
826 // Only update the source location if source map is in use.
827 this.updateSourceLocation(sourceUrl, sourceLine, sourceColumn);
832 this.domRule.on("location-changed", this.onLocationChanged);
836 * Handler for any location changes called from the SourceMapURLService and can also be
837 * called from onLocationChanged(). Updates the source location for the rule.
839 * @param {String} url
841 * @param {Number} line
842 * The original line number.
843 * @param {number} column
844 * The original column number.
846 updateSourceLocation(url, line, column) {
847 this._sourceLocation = {
852 this.store.dispatch(updateSourceLink(this.domRule.actorID, this.sourceLink));
856 module.exports = Rule;