Bug 994559 - Style used fonts in rule view. r=pbro draft
authorMichael Hoffmann <brennan.brisad@gmail.com>
Tue, 05 Dec 2017 15:02:07 +0100
changeset 707543 3df70e75ed453886995aacb728179600fe7444b6
parent 706949 195bb467e6cb5c8c5f5fb2858c0a55b2d0b9552d
child 742974 01ee29c32ef4efba87fdf1d613c33020831c26ea
push id92150
push userbmo:pbrosset@mozilla.com
push dateTue, 05 Dec 2017 14:03:27 +0000
reviewerspbro
bugs994559
milestone59.0a1
Bug 994559 - Style used fonts in rule view. r=pbro MozReview-Commit-ID: 8soJq32FjvL
devtools/client/inspector/rules/models/element-style.js
devtools/client/inspector/rules/test/browser.ini
devtools/client/inspector/rules/test/browser_rules_highlight-used-fonts.js
devtools/client/inspector/rules/views/text-property-editor.js
devtools/client/shared/output-parser.js
devtools/client/shared/test/browser_outputparser.js
devtools/client/themes/rules.css
--- a/devtools/client/inspector/rules/models/element-style.js
+++ b/devtools/client/inspector/rules/models/element-style.js
@@ -133,16 +133,36 @@ ElementStyle.prototype = {
       }
       return promiseWarn(e);
     });
     this.populated = populated;
     return this.populated;
   },
 
   /**
+   * Get the font families in use by the element.
+   *
+   * Returns a promise that will be resolved to a list of CSS family
+   * names.  The list might have duplicates.
+   */
+  getUsedFontFamilies: function () {
+    return new Promise((resolve, reject) => {
+      this.ruleView.styleWindow.requestIdleCallback(async () => {
+        try {
+          let fonts = await this.pageStyle.getUsedFontFaces(
+            this.element, { includePreviews: false });
+          resolve(fonts.map(font => font.CSSFamilyName));
+        } catch (e) {
+          reject(e);
+        }
+      });
+    });
+  },
+
+  /**
    * Put pseudo elements in front of others.
    */
   _sortRulesForPseudoElement: function () {
     this.rules = this.rules.sort((a, b) => {
       return (a.pseudoElement || "z") > (b.pseudoElement || "z");
     });
   },
 
--- a/devtools/client/inspector/rules/test/browser.ini
+++ b/devtools/client/inspector/rules/test/browser.ini
@@ -172,16 +172,17 @@ skip-if = (os == "win" && debug) # bug 9
 [browser_rules_grid-highlighter-on-reload.js]
 [browser_rules_grid-highlighter-restored-after-reload.js]
 [browser_rules_grid-toggle_01.js]
 [browser_rules_grid-toggle_01b.js]
 [browser_rules_grid-toggle_02.js]
 [browser_rules_grid-toggle_03.js]
 [browser_rules_grid-toggle_04.js]
 [browser_rules_guessIndentation.js]
+[browser_rules_highlight-used-fonts.js]
 [browser_rules_inherited-properties_01.js]
 [browser_rules_inherited-properties_02.js]
 [browser_rules_inherited-properties_03.js]
 [browser_rules_inherited-properties_04.js]
 [browser_rules_inline-source-map.js]
 [browser_rules_invalid.js]
 [browser_rules_invalid-source-map.js]
 [browser_rules_keybindings.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/browser_rules_highlight-used-fonts.js
@@ -0,0 +1,68 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests that a used font-family is highlighted in the rule-view.
+
+const TEST_URI = `
+  <style type="text/css">
+    #id1 {
+      font-family: foo, bar, sans-serif;
+    }
+    #id2 {
+      font-family: serif;
+    }
+    #id3 {
+      font-family: foo, monospace, monospace, serif;
+    }
+    #id4 {
+      font-family: foo, bar;
+    }
+  </style>
+  <div id="id1">Text</div>
+  <div id="id2">Text</div>
+  <div id="id3">Text</div>
+  <div id="id4">Text</div>
+`;
+
+// Tests that font-family properties in the rule-view correctly
+// indicates which font is in use.
+// Each entry in the test array should contain:
+// {
+//   selector: the rule-view selector to look for font-family in
+//   nb: the number of fonts this property should have
+//   used: the index of the font that should be highlighted, or
+//         -1 if none should be highlighted
+// }
+const TESTS = [
+  {selector: "#id1", nb: 3, used: 2}, // sans-serif
+  {selector: "#id2", nb: 1, used: 0}, // serif
+  {selector: "#id3", nb: 4, used: 1}, // monospace
+  {selector: "#id4", nb: 2, used: -1},
+];
+
+add_task(function* () {
+  yield addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+  let {inspector, view} = yield openRuleView();
+
+  for (let {selector, nb, used} of TESTS) {
+    let onFontHighlighted = view.once("font-highlighted");
+    yield selectNode(selector, inspector);
+    yield onFontHighlighted;
+
+    info("Looking for fonts in font-family property in selector " + selector);
+
+    let prop = getRuleViewProperty(view, selector, "font-family").valueSpan;
+    let fonts = prop.querySelectorAll(".ruleview-font-family");
+
+    ok(fonts.length, "Fonts found in the property");
+    is(fonts.length, nb, "Correct number of fonts found in the property");
+
+    const highlighted = [...fonts].filter(span => span.classList.contains("used-font"));
+
+    ok(highlighted.length <= 1, "No more than one font highlighted");
+    is([...fonts].findIndex(f => f === highlighted[0]), used, "Correct font highlighted");
+  }
+});
--- a/devtools/client/inspector/rules/views/text-property-editor.js
+++ b/devtools/client/inspector/rules/views/text-property-editor.js
@@ -23,29 +23,45 @@ 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";
 const INSET_POINT_TYPES = ["top", "right", "bottom", "left"];
+const FONT_FAMILY_CLASS = "ruleview-font-family";
 
 /*
  * 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"
 ];
 
+// In order to highlight the used fonts in font-family properties, we
+// retrieve the list of used fonts from the server. That always
+// returns the actually used font family name(s). If the property's
+// authored value is sans-serif for instance, the used font might be
+// arial instead.  So we need the list of all generic font family
+// names to underline those when we find them.
+const GENERIC_FONT_FAMILIES = [
+  "serif",
+  "sans-serif",
+  "cursive",
+  "fantasy",
+  "monospace",
+  "system-ui"
+];
+
 /**
  * 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
@@ -360,26 +376,60 @@ TextPropertyEditor.prototype = {
       colorSwatchClass: SHARED_SWATCH_CLASS + " " + COLOR_SWATCH_CLASS,
       filterClass: "ruleview-filter",
       filterSwatchClass: SHARED_SWATCH_CLASS + " " + FILTER_SWATCH_CLASS,
       flexClass: "ruleview-flex",
       gridClass: "ruleview-grid",
       shapeClass: "ruleview-shape",
       defaultColorType: !propDirty,
       urlClass: "theme-link",
+      fontFamilyClass: FONT_FAMILY_CLASS,
       baseURI: this.sheetHref,
       unmatchedVariableClass: "ruleview-variable-unmatched",
       isVariableInUse: varName => this.rule.elementStyle.getVariable(varName),
     };
     let frag = outputParser.parseCssProperty(name, val, parserOptions);
     this.valueSpan.innerHTML = "";
     this.valueSpan.appendChild(frag);
 
     this.ruleView.emit("property-value-updated", this.valueSpan);
 
+    // Highlight the currently used font in font-family properties.
+    // If we cannot find a match, highlight the first generic family instead.
+    let fontFamilySpans = this.valueSpan.querySelectorAll("." + FONT_FAMILY_CLASS);
+    if (fontFamilySpans.length && this.prop.enabled && !this.prop.overridden) {
+      this.rule.elementStyle.getUsedFontFamilies().then(families => {
+        const usedFontFamilies = families.map(font => font.toLowerCase());
+        let foundMatchingFamily = false;
+        let firstGenericSpan = null;
+
+        for (let span of fontFamilySpans) {
+          const authoredFont = span.textContent.toLowerCase();
+
+          if (!firstGenericSpan && GENERIC_FONT_FAMILIES.includes(authoredFont)) {
+            firstGenericSpan = span;
+          }
+
+          if (usedFontFamilies.includes(authoredFont)) {
+            span.classList.add("used-font");
+            foundMatchingFamily = true;
+            // We found the span to style, no need to continue with
+            // the remaining ones
+            break;
+          }
+        }
+
+        if (!foundMatchingFamily && firstGenericSpan) {
+          firstGenericSpan.classList.add("used-font");
+        }
+
+        this.ruleView.emit("font-highlighted", this.valueSpan);
+      }).catch(e => console.error("Could not get the list of font families", e));
+    }
+
     // 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.getTooltip("colorPicker").addSwatch(span, {
--- a/devtools/client/shared/output-parser.js
+++ b/devtools/client/shared/output-parser.js
@@ -88,16 +88,17 @@ OutputParser.prototype = {
     options = this._mergeOptions(options);
 
     options.expectCubicBezier = this.supportsType(name, CSS_TYPES.TIMING_FUNCTION);
     options.expectDisplay = name === "display";
     options.expectFilter = name === "filter";
     options.expectShape = name === "clip-path" ||
                           (name === "shape-outside"
                            && Services.prefs.getBoolPref(CSS_SHAPE_OUTSIDE_ENABLED_PREF));
+    options.expectFont = name === "font-family";
     options.supportsColor = this.supportsType(name, CSS_TYPES.COLOR) ||
                             this.supportsType(name, CSS_TYPES.GRADIENT);
 
     // The filter property is special in that we want to show the
     // swatch even if the value is invalid, because this way the user
     // can easily use the editor to fix it.
     if (options.expectFilter || this._cssPropertySupportsValue(name, value)) {
       return this._parse(value, options);
@@ -279,16 +280,17 @@ OutputParser.prototype = {
    * @param  {Boolean} stopAtCloseParen
    *         If true, stop at an umatched close paren.
    * @return {DocumentFragment}
    *         A document fragment.
    */
   _doParse: function (text, options, tokenStream, stopAtCloseParen) {
     let parenDepth = stopAtCloseParen ? 1 : 0;
     let outerMostFunctionTakesColor = false;
+    let fontFamilyNameParts = []; // Sequence of identifiers
 
     let colorOK = function () {
       return options.supportsColor ||
         (options.expectFilter && parenDepth === 1 &&
          outerMostFunctionTakesColor);
     };
 
     let angleOK = function (angle) {
@@ -296,16 +298,19 @@ OutputParser.prototype = {
     };
 
     let spaceNeeded = false;
     let done = false;
 
     while (!done) {
       let token = tokenStream.nextToken();
       if (!token) {
+        if (options.expectFont && fontFamilyNameParts.length !== 0) {
+          this._appendFontFamily(fontFamilyNameParts.join(" "), false, options);
+        }
         break;
       }
 
       if (token.tokenType === "comment") {
         // This doesn't change spaceNeeded, because we didn't emit
         // anything to the output.
         continue;
       }
@@ -377,16 +382,18 @@ OutputParser.prototype = {
             this._appendHighlighterToggle(token.text, options.flexClass);
           } else if (this._isDisplayGrid(text, token, options)) {
             this._appendHighlighterToggle(token.text, options.gridClass);
           } else if (colorOK() &&
                      colorUtils.isValidCSSColor(token.text, this.cssColor4)) {
             this._appendColor(token.text, options);
           } else if (angleOK(token.text)) {
             this._appendAngle(token.text, options);
+          } else if (options.expectFont) {
+            fontFamilyNameParts.push(token.text);
           } else {
             this._appendTextNode(text.substring(token.startOffset,
                                                 token.endOffset));
           }
           break;
 
         case "id":
         case "hash": {
@@ -412,30 +419,54 @@ OutputParser.prototype = {
           }
           break;
         case "url":
         case "bad_url":
           this._appendURL(text.substring(token.startOffset, token.endOffset),
                           token.text, options);
           break;
 
+        case "string":
+          if (options.expectFont) {
+            this._appendFontFamily(text.substring(token.startOffset, token.endOffset),
+                                   true, options);
+          } else {
+            this._appendTextNode(
+              text.substring(token.startOffset, token.endOffset));
+          }
+          break;
+
+        case "whitespace":
+          // When parsing font families, whitespaces will be
+          // translated to single spaces when joining together
+          // separate identifiers.
+          if (!options.expectFont) {
+            this._appendTextNode(
+              text.substring(token.startOffset, token.endOffset));
+          }
+          break;
+
         case "symbol":
           if (token.text === "(") {
             ++parenDepth;
           } else if (token.text === ")") {
             --parenDepth;
 
             if (stopAtCloseParen && parenDepth === 0) {
               done = true;
               break;
             }
 
             if (parenDepth === 0) {
               outerMostFunctionTakesColor = false;
             }
+          } else if (token.text === "," &&
+                     options.expectFont && fontFamilyNameParts.length !== 0) {
+            this._appendFontFamily(fontFamilyNameParts.join(" "), false, options);
+            fontFamilyNameParts = [];
           }
           // falls through
         default:
           this._appendTextNode(
             text.substring(token.startOffset, token.endOffset));
           break;
       }
 
@@ -1324,16 +1355,42 @@ OutputParser.prototype = {
 
       this._appendTextNode(trailer);
     } else {
       this._appendTextNode(match);
     }
   },
 
   /**
+   * Append a font family to the output.
+   *
+   * @param  {String} fontFamily
+   *         Font family to append
+   * @param  {Boolean} quoted
+   *         True if fontFamily is surrounded by quotes
+   * @param  {Object} options
+   *         Options object. For valid options and default values see
+   *         _mergeOptions().
+   */
+  _appendFontFamily: function (fontFamily, quoted, options) {
+    if (quoted) {
+      const quoteChar = fontFamily[0];
+      this._appendTextNode(quoteChar);
+      this._appendNode("span", {
+        class: options.fontFamilyClass
+      }, fontFamily.slice(1, -1));
+      this._appendTextNode(quoteChar);
+    } else {
+      this._appendNode("span", {
+        class: options.fontFamilyClass
+      }, fontFamily);
+    }
+  },
+
+  /**
    * Create a node.
    *
    * @param  {String} tagName
    *         Tag type e.g. "div"
    * @param  {Object} attributes
    *         e.g. {class: "someClass", style: "cursor:pointer"};
    * @param  {String} [value]
    *         If a value is included it will be appended as a text node inside
@@ -1434,16 +1491,17 @@ OutputParser.prototype = {
    *                                    // parser to skip the call to
    *                                    // _wrapFilter.  Used only for
    *                                    // previewing with the filter swatch.
    *           - flexClass: ""          // The class to use for the flex icon.
    *           - gridClass: ""          // The class to use for the grid icon.
    *           - shapeClass: ""         // The class to use for the shape icon.
    *           - supportsColor: false   // Does the CSS property support colors?
    *           - urlClass: ""           // The class to be used for url() links.
+   *           - fontFamilyClass: ""    // The class to be used for font families.
    *           - baseURI: undefined     // A string used to resolve
    *                                    // relative links.
    *           - isVariableInUse        // A function taking a single
    *                                    // argument, the name of a variable.
    *                                    // This should return the variable's
    *                                    // value, if it is in use; or null.
    *           - unmatchedVariableClass: ""
    *                                    // The class to use for a component
@@ -1462,16 +1520,17 @@ OutputParser.prototype = {
       colorClass: "",
       colorSwatchClass: "",
       filterSwatch: false,
       flexClass: "",
       gridClass: "",
       shapeClass: "",
       supportsColor: false,
       urlClass: "",
+      fontFamilyClass: "",
       baseURI: undefined,
       isVariableInUse: null,
       unmatchedVariableClass: null,
     };
 
     for (let item in overrides) {
       defaults[item] = overrides[item];
     }
--- a/devtools/client/shared/test/browser_outputparser.js
+++ b/devtools/client/shared/test/browser_outputparser.js
@@ -25,16 +25,17 @@ function* performTest() {
   let parser = new OutputParser(doc, cssProperties);
   testParseCssProperty(doc, parser);
   testParseCssVar(doc, parser);
   testParseURL(doc, parser);
   testParseFilter(doc, parser);
   testParseAngle(doc, parser);
   testParseShape(doc, parser);
   testParseVariable(doc, parser);
+  testParseFontFamily(doc, parser);
 
   host.destroy();
 }
 
 // Class name used in color swatch.
 var COLOR_TEST_CLASS = "test-class";
 
 // Create a new CSS color-parsing test.  |name| is the name of the CSS
@@ -76,17 +77,18 @@ function testParseCssProperty(doc, parse
 
     makeColorTest("background-image",
       "linear-gradient(to right, #F60 10%, rgba(0,0,0,1))",
       ["linear-gradient(to right, ", {name: "#F60"},
        " 10%, ", {name: "rgba(0,0,0,1)"},
        ")"]),
 
     // In "arial black", "black" is a font, not a color.
-    makeColorTest("font-family", "arial black", ["arial black"]),
+    // (The font-family parser creates a span)
+    makeColorTest("font-family", "arial black", ["<span>arial black</span>"]),
 
     makeColorTest("box-shadow", "0 0 1em red",
                   ["0 0 1em ", {name: "red"}]),
 
     makeColorTest("box-shadow",
       "0 0 1em red, 2px 2px 0 0 rgba(0,0,0,.5)",
       ["0 0 1em ", {name: "red"},
        ", 2px 2px 0 0 ",
@@ -456,8 +458,52 @@ function testParseVariable(doc, parser) 
 
     let target = doc.querySelector("div");
     target.appendChild(frag);
 
     is(target.innerHTML, test.expected, test.text);
     target.innerHTML = "";
   }
 }
+
+function testParseFontFamily(doc, parser) {
+  info("Test font-family parsing");
+  const tests = [
+    {
+      desc: "No fonts",
+      definition: "",
+      families: []
+    },
+    {
+      desc: "List of fonts",
+      definition: "Arial,Helvetica,sans-serif",
+      families: ["Arial", "Helvetica", "sans-serif"]
+    },
+    {
+      desc: "Fonts with spaces",
+      definition: "Open Sans",
+      families: ["Open Sans"]
+    },
+    {
+      desc: "Quoted fonts",
+      definition: "\"Arial\",'Open Sans'",
+      families: ["Arial", "Open Sans"]
+    },
+    {
+      desc: "Fonts with extra whitespace",
+      definition: " Open  Sans  ",
+      families: ["Open Sans"]
+    }
+  ];
+
+  for (let {desc, definition, families} of tests) {
+    info(desc);
+    let frag = parser.parseCssProperty("font-family", definition, {
+      fontFamilyClass: "ruleview-font-family"
+    });
+    let spans = frag.querySelectorAll(".ruleview-font-family");
+
+    is(spans.length, families.length, desc + " span count");
+    for (let i = 0; i < spans.length; i++) {
+      is(spans[i].textContent, families[i], desc + " span contents");
+    }
+  }
+}
--- a/devtools/client/themes/rules.css
+++ b/devtools/client/themes/rules.css
@@ -536,16 +536,20 @@
 .ruleview-overridden {
   text-decoration: line-through;
 }
 
 .theme-light .ruleview-overridden {
   text-decoration-color: var(--theme-content-color3);
 }
 
+.ruleview-font-family.used-font {
+  text-decoration: underline;
+}
+
 .styleinspector-propertyeditor {
   border: 1px solid #CCC;
   padding: 0;
   margin: -1px -3px -1px -1px;
 }
 
 .theme-firebug .styleinspector-propertyeditor {
   border: 1px solid var(--theme-splitter-color);