Bug 1443846 - Add swatch to mark rule as selected and toggle font inspector panel. draft
authorRazvan Caliman <rcaliman@mozilla.com>
Sun, 11 Mar 2018 17:05:36 +0100
changeset 770993 df67a8ef838e685fd23eb9054e40eece6653fd0c
parent 770928 b41c7c1ff91f49b1500b087ff23c288dd88f1fde
child 770994 40d7c30665cce03f9567553a64b4538af846214a
push id103549
push userbmo:rcaliman@mozilla.com
push dateThu, 22 Mar 2018 08:29:21 +0000
bugs1443846
milestone61.0a1
Bug 1443846 - Add swatch to mark rule as selected and toggle font inspector panel. To use, toggle pref to true: devtools.inspector.fonteditor.enabled - Introduces the ability for tools to mark one or more rules as "selected". Store rule references for tools to know here to map changes. - On mouse hover over any rule, show the font editor swatch. - On click on the font editor swatch, toggle the rule as "selected" and toggle the font inspector panel. MozReview-Commit-ID: 3eTzEOXkApl
devtools/client/inspector/rules/rules.js
devtools/client/inspector/rules/views/rule-editor.js
devtools/client/preferences/devtools.js
devtools/client/themes/rules.css
--- a/devtools/client/inspector/rules/rules.js
+++ b/devtools/client/inspector/rules/rules.js
@@ -35,16 +35,17 @@ const {debounce} = require("devtools/sha
 const EventEmitter = require("devtools/shared/event-emitter");
 const KeyShortcuts = require("devtools/client/shared/key-shortcuts");
 const clipboardHelper = require("devtools/shared/platform/clipboard");
 const AutocompletePopup = require("devtools/client/shared/autocomplete-popup");
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 const PREF_UA_STYLES = "devtools.inspector.showUserAgentStyles";
 const PREF_DEFAULT_COLOR_UNIT = "devtools.defaultColorUnit";
+const PREF_FONT_EDITOR = "devtools.inspector.fonteditor.enabled";
 const FILTER_CHANGED_TIMEOUT = 150;
 
 // This is used to parse user input when filtering.
 const FILTER_PROP_RE = /\s*([^:\s]*)\s*:\s*(.*?)\s*;?$/;
 // This is used to parse the filter search value to see if the filter
 // should be strict or not
 const FILTER_STRICT_RE = /\s*`(.*?)`\s*$/;
 const INSET_POINT_TYPES = ["top", "right", "bottom", "left"];
@@ -95,35 +96,41 @@ const INSET_POINT_TYPES = ["top", "right
  * @param {Object} store
  *        The CSS rule view can use this object to store metadata
  *        that might outlast the rule view, particularly the current
  *        set of disabled properties.
  * @param {PageStyleFront} pageStyle
  *        The PageStyleFront for communicating with the remote server.
  */
 function CssRuleView(inspector, document, store, pageStyle) {
+  EventEmitter.decorate(this);
+
   this.inspector = inspector;
   this.highlighters = inspector.highlighters;
   this.styleDocument = document;
   this.styleWindow = this.styleDocument.defaultView;
   this.store = store || {};
+  // References to rules marked by various editors where they intend to write changes.
+  // @see selectRule(), unselectRule()
+  this.selectedRules = {};
   this.pageStyle = pageStyle;
 
   // Allow tests to override debouncing behavior, as this can cause intermittents.
   this.debounce = debounce;
 
   this.cssProperties = getCssProperties(inspector.toolbox);
 
   this._outputParser = new OutputParser(document, this.cssProperties);
 
   this._onAddRule = this._onAddRule.bind(this);
   this._onContextMenu = this._onContextMenu.bind(this);
   this._onCopy = this._onCopy.bind(this);
   this._onFilterStyles = this._onFilterStyles.bind(this);
   this._onClearSearch = this._onClearSearch.bind(this);
+  this._onRuleSelected = this._onRuleSelected.bind(this);
   this._onTogglePseudoClassPanel = this._onTogglePseudoClassPanel.bind(this);
   this._onTogglePseudoClass = this._onTogglePseudoClass.bind(this);
   this._onToggleClassPanel = this._onToggleClassPanel.bind(this);
 
   let doc = this.styleDocument;
   this.element = doc.getElementById("ruleview-container-focusable");
   this.addRuleButton = doc.getElementById("ruleview-add-rule-button");
   this.searchField = doc.getElementById("ruleview-searchbox");
@@ -149,27 +156,29 @@ function CssRuleView(inspector, document
   this.addRuleButton.addEventListener("click", this._onAddRule);
   this.searchField.addEventListener("input", this._onFilterStyles);
   this.searchClearButton.addEventListener("click", this._onClearSearch);
   this.pseudoClassToggle.addEventListener("click", this._onTogglePseudoClassPanel);
   this.classToggle.addEventListener("click", this._onToggleClassPanel);
   this.hoverCheckbox.addEventListener("click", this._onTogglePseudoClass);
   this.activeCheckbox.addEventListener("click", this._onTogglePseudoClass);
   this.focusCheckbox.addEventListener("click", this._onTogglePseudoClass);
+  this.on("ruleview-rule-selected", this._onRuleSelected);
 
   this._handlePrefChange = this._handlePrefChange.bind(this);
   this._handleUAStylePrefChange = this._handleUAStylePrefChange.bind(this);
   this._handleDefaultColorUnitPrefChange =
     this._handleDefaultColorUnitPrefChange.bind(this);
 
   this._prefObserver = new PrefObserver("devtools.");
   this._prefObserver.on(PREF_UA_STYLES, this._handleUAStylePrefChange);
   this._prefObserver.on(PREF_DEFAULT_COLOR_UNIT, this._handleDefaultColorUnitPrefChange);
 
   this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES);
+  this.showFontEditor = Services.prefs.getBoolPref(PREF_FONT_EDITOR);
 
   // The popup will be attached to the toolbox document.
   this.popup = new AutocompletePopup(inspector._toolbox.doc, {
     autoSelect: true,
     theme: "auto"
   });
 
   this._showEmpty();
@@ -177,18 +186,16 @@ function CssRuleView(inspector, document
   this._contextmenu = new StyleInspectorMenu(this, { isRuleView: true });
 
   // Add the tooltips and highlighters to the view
   this.tooltips = new TooltipsOverlay(this);
 
   this.highlighters.addToView(this);
 
   this.classListPreviewer = new ClassListPreviewer(this.inspector, this.classPanel);
-
-  EventEmitter.decorate(this);
 }
 
 CssRuleView.prototype = {
   // The element that we're inspecting.
   _viewedElement: null,
 
   // Used for cancelling timeouts in the style filter.
   _filterChangedTimeout: null,
@@ -715,29 +722,31 @@ CssRuleView.prototype = {
     if (this._contextmenu) {
       this._contextmenu.destroy();
       this._contextmenu = null;
     }
 
     this.tooltips.destroy();
     this.highlighters.removeFromView(this);
     this.classListPreviewer.destroy();
+    this.unselectAllRules();
 
     // Remove bound listeners
     this.shortcuts.destroy();
     this.element.removeEventListener("copy", this._onCopy);
     this.element.removeEventListener("contextmenu", this._onContextMenu);
     this.addRuleButton.removeEventListener("click", this._onAddRule);
     this.searchField.removeEventListener("input", this._onFilterStyles);
     this.searchClearButton.removeEventListener("click", this._onClearSearch);
     this.pseudoClassToggle.removeEventListener("click", this._onTogglePseudoClassPanel);
     this.classToggle.removeEventListener("click", this._onToggleClassPanel);
     this.hoverCheckbox.removeEventListener("click", this._onTogglePseudoClass);
     this.activeCheckbox.removeEventListener("click", this._onTogglePseudoClass);
     this.focusCheckbox.removeEventListener("click", this._onTogglePseudoClass);
+    this.off("ruleview-rule-selected", this._onRuleSelected);
 
     this.searchField = null;
     this.searchClearButton = null;
     this.pseudoClassPanel = null;
     this.pseudoClassToggle = null;
     this.classPanel = null;
     this.classToggle = null;
     this.hoverCheckbox = null;
@@ -794,16 +803,17 @@ CssRuleView.prototype = {
       this.popup.hidePopup();
     }
 
     this.clear(false);
     this._viewedElement = element;
 
     this.clearPseudoClassPanel();
     this.refreshAddRuleButtonState();
+    this.unselectAllRules();
 
     if (!this._viewedElement) {
       this._stopSelectingElement();
       this._clearRules();
       this._showEmpty();
       this.refreshPseudoClassPanel();
       return promise.resolve(undefined);
     }
@@ -1189,16 +1199,123 @@ CssRuleView.prototype = {
         isHighlighted = true;
       }
     }
 
     return isHighlighted;
   },
 
   /**
+  * Mark a rule as selected for the given editor id.
+  *
+  * Editing tools can mark one or more rules as selected for themselves so they have
+  * a reference of where to make changes, like add / remove properties.
+  * Each editor has an identifier string (aka editorId) which is used as a key in a map
+  * that holds references to Rule objects.
+  *
+  * Many editors may operate at the same time (ex: Font Editor and Shape Path Editor) so
+  * there are multiple possible selected rules at any given time. A rule can be selected
+  * by different editors at the same time, with each editor operating independently on it.
+  *
+  * @param {Rule} rule
+  *        Rule object for which to hold a reference.
+  * @param {String} editorId
+  *        Key to use for collecting references to selected rules.
+  * @param {Boolean} [unselectOthers=true]
+  *        Optional. Default: `true`. If true, unselect all other rules that were
+  *        selected for the given editor. Ensures only one rule at a time is selected for
+  *        a particular editor. Set to `false` if an editor may operate on multiple rules
+  *        at a time.
+  */
+  selectRule(rule, editorId, unselectOthers = true) {
+    this.selectedRules[editorId] = this.getSelectedRules(editorId);
+
+    if (!this.selectedRules[editorId].includes(rule)) {
+      this.selectedRules[editorId].push(rule);
+    }
+
+    // Mark other rules for this editorId as unselected.
+    if (unselectOthers) {
+      this.selectedRules[editorId]
+        .filter(item => item !== rule)
+        .map(item => this.unselectRule(item, editorId));
+    }
+
+    this.emit("ruleview-rule-selected", {editorId, rule});
+  },
+
+  /**
+   * Unmark a rule as selected for the given editor id.
+   *
+   * @param {Rule} rule
+   *        Rule object for which to remove the reference.
+   * @param {String} editorId
+   *        Key for which to mark the given rule as selected.
+   */
+  unselectRule(rule, editorId) {
+    if (!Array.isArray(this.selectedRules[editorId])) {
+      return;
+    }
+
+    let index = this.selectedRules[editorId].findIndex(item => item === rule);
+    if (index === -1) {
+      return;
+    }
+
+    this.selectedRules[editorId].splice(index, 1);
+    this.emit("ruleview-rule-unselected", {editorId, rule});
+  },
+
+  /**
+  * Unmark all selected rules for all editors. If an editor id is provided, unmark all
+  * selected rules just for that editor leaving others untouched.
+  *
+  * @param {String} [editorId]
+  *        Optional editor id for which to restrict unselect operation.
+  */
+  unselectAllRules(editorId) {
+    let keys = Object.keys(this.selectedRules);
+    keys = editorId ? keys.filter(key => (key === editorId)) : keys;
+    for (let key of keys) {
+      this.selectedRules[key].map(item => this.unselectRule(item, key));
+    }
+  },
+
+  /**
+   * Return an array of selected rules for the given editor id.
+   * If no rules match, return an empty arrary;
+   *
+   * @param {String} editorId
+   *        Editor id for which to return selected rules.
+   * @return {Array}
+   */
+  getSelectedRules(editorId) {
+    return Array.isArray(this.selectedRules[editorId]) ?
+      this.selectedRules[editorId] : [];
+  },
+
+  /**
+   * Called when a rule from the Rule view was marked as selected for an editor.
+   * Handle the event and show panels relevant for the given editor id.
+   *
+   * @param {Object} eventData
+   *        Data payload for the event. Contains:
+   *        - {String} editorId - id of the editor for which the rule was selected
+   *        - {Rule} rule - reference to rule that was selected
+   */
+  _onRuleSelected(eventData) {
+    const { editorId } = eventData;
+    switch (editorId) {
+      case "fonteditor":
+        this.inspector.sidebar.show("fontinspector");
+        break;
+    }
+  },
+
+  /**
    * Highlights the rule selector that matches the filter search value and
    * returns a boolean indicating whether or not the selector was highlighted.
    *
    * @param  {Rule} rule
    *         The Rule object.
    * @return {Boolean} true if the rule selector was highlighted,
    *         false otherwise.
    */
--- a/devtools/client/inspector/rules/views/rule-editor.js
+++ b/devtools/client/inspector/rules/views/rule-editor.js
@@ -60,37 +60,45 @@ function RuleEditor(ruleView, rule) {
   this.sourceMapURLService = this.toolbox.sourceMapURLService;
   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._onFontSwatchClick = this._onFontSwatchClick.bind(this);
   this._onNewProperty = this._onNewProperty.bind(this);
   this._newPropertyDestroy = this._newPropertyDestroy.bind(this);
   this._onSelectorDone = this._onSelectorDone.bind(this);
   this._locationChanged = this._locationChanged.bind(this);
   this.updateSourceLink = this.updateSourceLink.bind(this);
   this._onToolChanged = this._onToolChanged.bind(this);
   this._updateLocation = this._updateLocation.bind(this);
   this._onSourceClick = this._onSourceClick.bind(this);
+  this._onRuleUnselected = this._onRuleUnselected.bind(this);
 
   this.rule.domRule.on("location-changed", this._locationChanged);
   this.toolbox.on("tool-registered", this._onToolChanged);
   this.toolbox.on("tool-unregistered", this._onToolChanged);
+  this.ruleView.on("ruleview-rule-unselected", this._onRuleUnselected);
 
   this._create();
 }
 
 RuleEditor.prototype = {
   destroy: function() {
     this.rule.domRule.off("location-changed");
     this.toolbox.off("tool-registered", this._onToolChanged);
     this.toolbox.off("tool-unregistered", this._onToolChanged);
+    this.ruleView.off("ruleview-rule-unselected", this._onRuleUnselected);
+
+    if (this.fontSwatch) {
+      this.fontSwatch.removeEventListener("click", this._onFontSwatchClick);
+    }
 
     let url = null;
     if (this.rule.sheet) {
       url = this.rule.sheet.href || this.rule.sheet.nodeHref;
     }
     if (url && !this.rule.isSystem && this.rule.domRule.type !== ELEMENT_STYLE) {
       // Only get the original source link if the rule isn't a system
       // rule and if it isn't an inline rule.
@@ -230,16 +238,64 @@ RuleEditor.prototype = {
         this.doc.defaultView.focus();
       });
 
       // Create a property editor when the close brace is clicked.
       editableItem({ element: this.closeBrace }, () => {
         this.newProperty();
       });
     }
+
+    // Create the font editor toggle icon visible on hover.
+    if (this.ruleView.showFontEditor) {
+      this.fontSwatch = createChild(this.element, "div", {
+        class: "ruleview-fontswatch"
+      });
+
+      // TODO: replace with tool icon and use this as visually hidden a11y text.
+      this.fontSwatch.textContent = "Aa";
+      this.fontSwatch.addEventListener("click", this._onFontSwatchClick);
+    }
+  },
+
+  /**
+   * Handler for clicks on font swatch icon.
+   * Toggles the selected state of the the current rule for the font editor.
+   *
+   * @param {MouseEvent} e
+   *        Mouse click event.
+   */
+  _onFontSwatchClick: function(e) {
+    const editorId = "fonteditor";
+    const isActive = e.target.classList.toggle("active");
+
+    if (isActive) {
+      this.ruleView.selectRule(this.rule, editorId);
+    } else {
+      this.ruleView.unselectRule(this.rule, editorId);
+    }
+  },
+
+  /**
+   * Called when a rule was released from being selected for an editor.
+   * A rule may be released by: toggling a swatch icon, an action from an editor
+   * (ex: close), selecting a different node in the markup view, etc.
+   *
+   * @param {Object} eventData
+   *        Data payload for the event. Contains:
+   *        - {String} editorId - id of the editor for which the rule was released
+   *        - {Rule} rule - reference to rule that was released
+   */
+  _onRuleUnselected: function(eventData) {
+    const { rule, editorId } = eventData;
+
+    // If no longer selected for the font editor, toggle the swatch icon.
+    if (editorId === "fonteditor" && rule == this.rule) {
+      this.fontSwatch.classList.remove("active");
+    }
   },
 
   /**
    * Called when a tool is registered or unregistered.
    */
   _onToolChanged: function() {
     // When the source editor is registered, update the source links
     // to be clickable; and if it is unregistered, update the links to
--- a/devtools/client/preferences/devtools.js
+++ b/devtools/client/preferences/devtools.js
@@ -69,16 +69,18 @@ pref("devtools.inspector.shapesHighlight
 // Enable the Changes View
 pref("devtools.changesview.enabled", false);
 // Enable the Events View
 pref("devtools.eventsview.enabled", false);
 // Enable the Flexbox Inspector panel
 pref("devtools.flexboxinspector.enabled", false);
 // Enable the new Animation Inspector
 pref("devtools.new-animationinspector.enabled", false);
+// Enable the Variable Fonts editor
+pref("devtools.inspector.fonteditor.enabled", false);
 
 // Grid highlighter preferences
 pref("devtools.gridinspector.gridOutlineMaxColumns", 50);
 pref("devtools.gridinspector.gridOutlineMaxRows", 50);
 pref("devtools.gridinspector.showGridAreas", false);
 pref("devtools.gridinspector.showGridLineNumbers", false);
 pref("devtools.gridinspector.showInfiniteLines", false);
 
--- a/devtools/client/themes/rules.css
+++ b/devtools/client/themes/rules.css
@@ -512,16 +512,39 @@
   background-size: 1em;
 }
 
 .ruleview-angleswatch {
   background: url("chrome://devtools/skin/images/angle-swatch.svg");
   background-size: 1em;
 }
 
+.ruleview-rule:not(:hover) .ruleview-fontswatch:not(.active) {
+  visibility: hidden;
+}
+
+.ruleview-fontswatch {
+  background-color: var(--grey-40);
+  background-size: 1em;
+  color: white;
+  cursor: pointer;
+
+  font-size: .8em;
+  position: absolute;
+  right: 2em;
+  bottom: .5em;
+  padding: .1em .2em;
+
+  -moz-user-select: none;
+}
+
+.ruleview-fontswatch.active {
+  background-color: var(--blue-50);
+}
+
 .ruleview-shapeswatch {
   background: url("chrome://devtools/skin/images/tool-shadereditor.svg");
   -moz-context-properties: fill;
   fill: var(--rule-shape-toggle-color);
   border-radius: 0;
   background-size: 1em;
   box-shadow: none;
 }