Bug 1449885 - Read font variation axis data and setup UI with any axis values defined on rule. r=gl draft
authorRazvan Caliman <rcaliman@mozilla.com>
Mon, 02 Apr 2018 17:32:51 +0200
changeset 779277 ec5f0bde6a66952fa56a32f85e701526277b4ca4
parent 779146 30d72755b1749953d438199456f1524ce84ab5e5
child 779278 e8576d9fe0e5472897aaa6b999276840959d1d4c
push id105733
push userbmo:rcaliman@mozilla.com
push dateMon, 09 Apr 2018 16:11:10 +0000
reviewersgl
bugs1449885
milestone61.0a1
Bug 1449885 - Read font variation axis data and setup UI with any axis values defined on rule. r=gl MozReview-Commit-ID: 6tWRyjYcdDH
devtools/client/inspector/fonts/actions/font-editor.js
devtools/client/inspector/fonts/actions/index.js
devtools/client/inspector/fonts/components/FontAxis.js
devtools/client/inspector/fonts/components/FontEditor.js
devtools/client/inspector/fonts/components/FontsApp.js
devtools/client/inspector/fonts/components/moz.build
devtools/client/inspector/fonts/fonts.js
devtools/client/inspector/fonts/reducers/font-editor.js
devtools/client/inspector/fonts/types.js
devtools/client/themes/fonts.css
--- a/devtools/client/inspector/fonts/actions/font-editor.js
+++ b/devtools/client/inspector/fonts/actions/font-editor.js
@@ -1,21 +1,46 @@
 /* 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 {
+  RESET_EDITOR,
+  UPDATE_AXIS_VALUE,
   UPDATE_EDITOR_VISIBILITY,
+  UPDATE_EDITOR_STATE,
 } = require("./index");
 
 module.exports = {
 
+  resetFontEditor() {
+    return {
+      type: RESET_EDITOR,
+    };
+  },
+
   toggleFontEditor(isVisible, selector = "") {
     return {
       type: UPDATE_EDITOR_VISIBILITY,
       isVisible,
       selector,
     };
   },
 
+  updateAxis(axis, value) {
+    return {
+      type: UPDATE_AXIS_VALUE,
+      axis,
+      value,
+    };
+  },
+
+  updateFontEditor(fonts, properties = {}) {
+    return {
+      type: UPDATE_EDITOR_STATE,
+      fonts,
+      properties,
+    };
+  },
+
 };
--- a/devtools/client/inspector/fonts/actions/index.js
+++ b/devtools/client/inspector/fonts/actions/index.js
@@ -3,16 +3,25 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const { createEnum } = require("devtools/client/shared/enum");
 
 createEnum([
 
+  // Reset font editor to intial state.
+  "RESET_EDITOR",
+
+  // Update the value of a variable font axis.
+  "UPDATE_AXIS_VALUE",
+
+  // Update font editor with applicable fonts and user-defined CSS font properties.
+  "UPDATE_EDITOR_STATE",
+
   // Toggle the visibiltiy of the font editor
   "UPDATE_EDITOR_VISIBILITY",
 
   // Update the list of fonts.
   "UPDATE_FONTS",
 
   // Update the preview text.
   "UPDATE_PREVIEW_TEXT",
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontAxis.js
@@ -0,0 +1,78 @@
+/* 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 { PureComponent } = require("devtools/client/shared/vendor/react");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+
+class FontAxis extends PureComponent {
+  static get propTypes() {
+    return {
+      defaultValue: PropTypes.oneOfType([ PropTypes.string, PropTypes.number ]),
+      label: PropTypes.string.isRequired,
+      min: PropTypes.oneOfType([ PropTypes.string, PropTypes.number ]),
+      max: PropTypes.oneOfType([ PropTypes.string, PropTypes.number ]),
+      name: PropTypes.string.isRequired,
+      onChange: PropTypes.func.isRequired,
+      showInput: PropTypes.bool,
+      step: PropTypes.oneOfType([ PropTypes.string, PropTypes.number ]),
+      value: PropTypes.string,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    this.onChange = this.onChange.bind(this);
+  }
+
+  onChange(e) {
+    this.props.onChange(this.props.name, e.target.value);
+  }
+
+  render() {
+    const defaults = {
+      min: this.props.min,
+      max: this.props.max,
+      onChange: this.onChange,
+      step: this.props.step || 1,
+      value: this.props.value || this.props.defaultValue,
+    };
+
+    const range = dom.input(
+      {
+        ...defaults,
+        className: "font-axis-slider",
+        title: this.props.label,
+        type: "range",
+      }
+    );
+
+    const input = dom.input(
+      {
+        ...defaults,
+        className: "font-axis-input",
+        type: "number",
+      }
+    );
+
+    return dom.label(
+      {
+        className: "font-axis",
+      },
+      dom.span(
+        {
+          className: "font-axis-label",
+        },
+        this.props.label
+      ),
+      range,
+      this.props.showInput ? input : null
+    );
+  }
+}
+
+module.exports = FontAxis;
--- a/devtools/client/inspector/fonts/components/FontEditor.js
+++ b/devtools/client/inspector/fonts/components/FontEditor.js
@@ -1,32 +1,101 @@
 /* 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 { PureComponent } = require("devtools/client/shared/vendor/react");
+const { createFactory, PureComponent } = require("devtools/client/shared/vendor/react");
 const dom = require("devtools/client/shared/vendor/react-dom-factories");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 
+const FontAxis = createFactory(require("./FontAxis"));
+
 const Types = require("../types");
 
 class FontEditor extends PureComponent {
   static get propTypes() {
     return {
       fontEditor: PropTypes.shape(Types.fontEditor).isRequired,
+      onAxisUpdate: PropTypes.func.isRequired,
     };
   }
 
+  /**
+   * Naive implementation to get increment step for variable font axis that ensures
+   * a wide spectrum of precision based on range of values between min and max.
+   *
+   * @param {Number|String} min
+   *        Minumum value for range.
+   * @param {Number|String} max
+   *        Maximum value for range.
+   * @return {String}
+   *        Step value used in range input for font axis.
+   */
+  getAxisStep(min, max) {
+    let step = 1;
+    let delta = parseInt(max, 10) - parseInt(min, 10);
+
+    if (delta <= 1) {
+      step = 0.001;
+    } else if (delta <= 10) {
+      step = 0.01;
+    } else if (delta <= 100) {
+      step = 0.1;
+    }
+
+    return step.toString();
+  }
+
+  /**
+   * Get an array of FontAxis components for of the given variable font axis instances.
+   * If an axis is defined in the fontEditor store, use its value, else use the default.
+   *
+   * @param {Array} fontAxes
+   *        Array of font axis instances
+   * @param {Object} editedAxes
+   *        Object with axes and values edited by the user or predefined in the CSS
+   *        declaration for font-variation-settings.
+   * @return {Array}
+  *          Array of FontAxis components
+   */
+  renderAxes(fontAxes = [], editedAxes) {
+    return fontAxes.map(axis => {
+      return FontAxis({
+        min: axis.minValue,
+        max: axis.maxValue,
+        value: editedAxes[axis.tag] || axis.defaultValue,
+        step: this.getAxisStep(axis.minValue, axis.maxValue),
+        label: axis.name,
+        name: axis.tag,
+        onChange: this.props.onAxisUpdate,
+        showInput: true
+      });
+    });
+  }
+
+  // Placeholder for non-variable font UI.
+  // Bug https://bugzilla.mozilla.org/show_bug.cgi?id=1450695
+  renderPlaceholder() {
+    return dom.div({}, "No fonts with variation axes apply to this element.");
+  }
+
   render() {
-    const { selector } = this.props.fontEditor;
+    const { fonts, axes } = this.props.fontEditor;
+    // For MVP use ony first font to show axes if available.
+    // Future implementations will allow switching between multiple fonts.
+    const fontAxes = (fonts[0] && fonts[0].variationAxes) ? fonts[0].variationAxes : null;
 
     return dom.div(
       {
         className: "theme-sidebar inspector-tabpanel",
-        id: "sidebar-panel-fonteditor"
-      }, `Placeholder for Font Editor panel for selector: ${selector}`
+        id: "sidebar-panel-fontinspector"
+      },
+      fontAxes ?
+        this.renderAxes(fontAxes, axes)
+        :
+        this.renderPlaceholder()
     );
   }
 }
 
 module.exports = FontEditor;
--- a/devtools/client/inspector/fonts/components/FontsApp.js
+++ b/devtools/client/inspector/fonts/components/FontsApp.js
@@ -15,36 +15,39 @@ const FontOverview = createFactory(requi
 const Types = require("../types");
 
 class FontsApp extends PureComponent {
   static get propTypes() {
     return {
       fontData: PropTypes.shape(Types.fontData).isRequired,
       fontEditor: PropTypes.shape(Types.fontEditor).isRequired,
       fontOptions: PropTypes.shape(Types.fontOptions).isRequired,
+      onAxisUpdate: PropTypes.func.isRequired,
       onPreviewFonts: PropTypes.func.isRequired,
     };
   }
 
   render() {
     const {
       fontData,
       fontEditor,
       fontOptions,
-      onPreviewFonts
+      onAxisUpdate,
+      onPreviewFonts,
     } = this.props;
 
     return dom.div(
       {
         className: "theme-sidebar inspector-tabpanel",
         id: "sidebar-panel-fontinspector"
       },
       fontEditor.isVisible ?
         FontEditor({
           fontEditor,
+          onAxisUpdate,
         })
         :
         FontOverview({
           fontData,
           fontOptions,
           onPreviewFonts,
         })
     );
--- a/devtools/client/inspector/fonts/components/moz.build
+++ b/devtools/client/inspector/fonts/components/moz.build
@@ -1,14 +1,15 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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(
     'Font.js',
+    'FontAxis.js',
     'FontEditor.js',
     'FontList.js',
     'FontOverview.js',
     'FontPreview.js',
     'FontsApp.js',
 )
--- a/devtools/client/inspector/fonts/fonts.js
+++ b/devtools/client/inspector/fonts/fonts.js
@@ -14,46 +14,57 @@ const { Provider } = require("devtools/c
 const FontsApp = createFactory(require("./components/FontsApp"));
 
 const { LocalizationHelper } = require("devtools/shared/l10n");
 const INSPECTOR_L10N =
   new LocalizationHelper("devtools/client/locales/inspector.properties");
 
 const { updateFonts } = require("./actions/fonts");
 const { updatePreviewText } = require("./actions/font-options");
-const { toggleFontEditor } = require("./actions/font-editor");
+const { resetFontEditor, toggleFontEditor, updateAxis, updateFontEditor } =
+  require("./actions/font-editor");
 
 const FONT_EDITOR_ID = "fonteditor";
+const FONT_PROPERTIES = [
+  "font-optical-sizing",
+  "font-size",
+  "font-stretch",
+  "font-style",
+  "font-variation-settings",
+  "font-weight",
+];
 
 class FontInspector {
   constructor(inspector, window) {
     this.document = window.document;
     this.inspector = inspector;
     this.pageStyle = this.inspector.pageStyle;
     this.ruleView = this.inspector.getPanel("ruleview").view;
     this.selectedRule = null;
     this.store = this.inspector.store;
 
     this.update = this.update.bind(this);
+    this.onAxisUpdate = this.onAxisUpdate.bind(this);
     this.onNewNode = this.onNewNode.bind(this);
     this.onPreviewFonts = this.onPreviewFonts.bind(this);
     this.onRuleSelected = this.onRuleSelected.bind(this);
     this.onRuleUnselected = this.onRuleUnselected.bind(this);
     this.onThemeChanged = this.onThemeChanged.bind(this);
 
     this.init();
   }
 
   init() {
     if (!this.inspector) {
       return;
     }
 
     let fontsApp = FontsApp({
       onPreviewFonts: this.onPreviewFonts,
+      onAxisUpdate: this.onAxisUpdate,
     });
 
     let provider = createElement(Provider, {
       id: "fontinspector",
       key: "fontinspector",
       store: this.store,
       title: INSPECTOR_L10N.getStr("inspector.sidebar.fontInspectorTitle"),
     }, fontsApp);
@@ -141,16 +152,29 @@ class FontInspector {
    * Returns true if the font inspector panel is visible, and false otherwise.
    */
   isPanelVisible() {
     return this.inspector.sidebar &&
            this.inspector.sidebar.getCurrentTabID() === "fontinspector";
   }
 
   /**
+   * Handler for changes of font axis value. Updates the value in the store and previews
+   * the change on the page.
+   *
+   * @param {String} tag
+   *        Tag name of the font axis.
+   * @param {String} value
+   *        Value of the font axis.
+   */
+  onAxisUpdate(tag, value) {
+    this.store.dispatch(updateAxis(tag, value));
+  }
+
+  /**
    * Selection 'new-node' event handler.
    */
   onNewNode() {
     if (this.isPanelVisible()) {
       this.update();
     }
   }
 
@@ -168,21 +192,23 @@ class FontInspector {
    * If selected for the font editor, hold a reference to the rule so we know where to
    * put property changes coming from the font editor and show the font editor panel.
    *
    * @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) {
+  async onRuleSelected(eventData) {
     const { editorId, rule } = eventData;
     if (editorId === FONT_EDITOR_ID) {
       const selector = rule.matchedSelectors[0];
       this.selectedRule = rule;
+
+      await this.refreshFontEditor();
       this.store.dispatch(toggleFontEditor(true, selector));
     }
   }
 
   /**
    * Handler for "ruleview-rule-unselected" event emitted from the rule view when a rule
    * was released from being selected for an editor.
    * If previously selected for the font editor, release the reference to the rule and
@@ -193,28 +219,69 @@ class FontInspector {
    *        - {String} editorId - id of the editor for which the rule was released
    *        - {Rule} rule - reference to rule that was released
    */
   onRuleUnselected(eventData) {
     const { editorId, rule } = eventData;
     if (editorId === FONT_EDITOR_ID && rule == this.selectedRule) {
       this.selectedRule = null;
       this.store.dispatch(toggleFontEditor(false));
+      this.store.dispatch(resetFontEditor());
     }
   }
 
   /**
    * Handler for the "theme-switched" event.
    */
   onThemeChanged(frame) {
     if (frame === this.document.defaultView) {
       this.update();
     }
   }
 
+  /**
+   * Update the state of the font editor with:
+   * - the fonts which apply to the current node;
+   * - the CSS font properties declared on the selected rule.
+   *
+   * This method is called during initial setup and as a result of any property
+   * values change in the Rule view. For the latter case, we do a deep compare between the
+   * font properties on the selected rule and the ones already store to decide if to
+   * update the font edtior to reflect a new external state.
+   */
+  async refreshFontEditor() {
+    if (!this.selectedRule || !this.inspector || !this.store) {
+      return;
+    }
+
+    const options = {};
+    if (this.pageStyle.supportsFontVariations) {
+      options.includeVariations = true;
+    }
+
+    const node = this.inspector.selection.nodeFront;
+    const fonts = await this.getFontsForNode(node, options);
+    // Collect any expected font properties and their values from the selected rule.
+    const properties = this.selectedRule.textProps.reduce((acc, prop) => {
+      if (FONT_PROPERTIES.includes(prop.name)) {
+        acc[prop.name] = prop.value;
+      }
+
+      return acc;
+    }, {});
+
+    const fontEditor = this.store.getState().fontEditor;
+
+    // Update the font editor state only if property values in rule differ from store.
+    // This can happen when a user makes manual edits to the values in the rule view.
+    if (JSON.stringify(properties) !== JSON.stringify(fontEditor.properties)) {
+      this.store.dispatch(updateFontEditor(fonts, properties));
+    }
+  }
+
   async update() {
     // Stop refreshing if the inspector or store is already destroyed.
     if (!this.inspector || !this.store) {
       return;
     }
 
     let node = this.inspector.selection.nodeFront;
 
--- a/devtools/client/inspector/fonts/reducers/font-editor.js
+++ b/devtools/client/inspector/fonts/reducers/font-editor.js
@@ -1,27 +1,67 @@
 /* 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 {
+  RESET_EDITOR,
+  UPDATE_AXIS_VALUE,
+  UPDATE_EDITOR_STATE,
   UPDATE_EDITOR_VISIBILITY,
 } = require("../actions/index");
 
 const INITIAL_STATE = {
+  // Variable font axes.
+  axes: {},
+  // Fonts applicable to selected element.
+  fonts: [],
   // Whether or not the font editor is visible.
   isVisible: false,
-  // Selector text of the rule where font properties will be written.
+  // CSS font properties defined on the selected rule.
+  properties: {},
+  // Selector text of the selected rule where updated font properties will be written.
   selector: "",
 };
 
 let reducers = {
 
+  [RESET_EDITOR](state) {
+    return { ...INITIAL_STATE };
+  },
+
+  [UPDATE_AXIS_VALUE](state, { axis, value }) {
+    let newState = { ...state };
+    newState.axes[axis] = value;
+    return newState;
+  },
+
+  [UPDATE_EDITOR_STATE](state, { fonts, properties }) {
+    let axes = {};
+
+    if (properties["font-variation-settings"]) {
+      // Parse font-variation-settings CSS declaration into an object
+      // with axis tags as keys and axis values as values.
+      axes = properties["font-variation-settings"]
+        .split(",")
+        .reduce((acc, pair) => {
+          // Tags are always in quotes. Split by quote and filter excessive whitespace.
+          pair = pair.split(/["']/).filter(part => part.trim() !== "");
+          const tag = pair[0].trim();
+          const value = pair[1].trim();
+          acc[tag] = value;
+          return acc;
+        }, {});
+    }
+
+    return { ...state, axes, fonts, properties };
+  },
+
   [UPDATE_EDITOR_VISIBILITY](state, { isVisible, selector }) {
     return { ...state, isVisible, selector };
   },
 
 };
 
 module.exports = function(state = INITIAL_STATE, action) {
   let reducer = reducers[action.type];
--- a/devtools/client/inspector/fonts/types.js
+++ b/devtools/client/inspector/fonts/types.js
@@ -75,22 +75,28 @@ const font = exports.font = {
 };
 
 exports.fontOptions = {
   // The current preview text
   previewText: PropTypes.string,
 };
 
 exports.fontEditor = {
-  // Font currently being edited
-  font: PropTypes.shape(font),
+  // Variable font axes and their values
+  axes: PropTypes.object,
+
+  // Fonts applicable to selected element
+  fonts: PropTypes.arrayOf(PropTypes.shape(font)),
 
   // Whether or not the font editor is visible
   isVisible: PropTypes.bool,
 
+  // CSS font properties defined on the element
+  properties: PropTypes.object,
+
   // Selector text of the rule where font properties will be written
   selector: PropTypes.string,
 };
 
 /**
  * Font data.
  */
 exports.fontData = {
--- a/devtools/client/themes/fonts.css
+++ b/devtools/client/themes/fonts.css
@@ -3,24 +3,20 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 #sidebar-panel-fontinspector {
   margin: 0;
   display: flex;
   flex-direction: column;
   width: 100%;
   height: 100%;
-}
-
-#sidebar-panel-fonteditor {
-  padding: 1em;
+  overflow: auto;
 }
 
 #font-container {
-  overflow: auto;
   flex: auto;
 }
 
 .fonts-list {
   padding: 0;
   margin: 0;
   list-style: none;
 }
@@ -108,16 +104,49 @@
   color: var(--theme-body-color-inactive);
   border-radius: 3px;
   border-style: solid;
   border-width: 1px;
   text-align: center;
   vertical-align: middle;
 }
 
+.font-axis {
+  display: flex;
+  flex-direction: row nowrap;
+  justify-content: space-between;
+  align-items: center;
+  padding: 5px 20px;
+}
+
+.font-axis-input {
+  width: 60px;
+}
+
+.font-axis-label {
+  width: 70px;
+}
+
+.font-axis-slider {
+  flex: 1;
+}
+
+.font-axis-slider::-moz-focus-outer {
+  border: 0;
+}
+
+.font-axis-slider::-moz-range-thumb {
+  background: var(--grey-50);
+  border: 0;
+}
+
+.font-axis-slider:focus::-moz-range-thumb {
+  background: var(--blue-55);
+}
+
 .font-origin {
   margin-top: .2em;
   color: var(--grey-50);
   justify-self: start;
 }
 
 .font-origin.system {
   text-transform: capitalize;