Bug 1440855 - New font text-run highlighter used from the font inspector;r=gl draft
authorPatrick Brosset <pbrosset@mozilla.com>
Fri, 16 Feb 2018 17:00:54 +0100
changeset 763870 c1e845f1b47603341eca52fa98143597d4efe41e
parent 763557 709eae4e54ffa3f3518745516dd5d27a05255af2
push id101578
push userbmo:pbrosset@mozilla.com
push dateTue, 06 Mar 2018 20:03:01 +0000
reviewersgl
bugs1440855
milestone60.0a1
Bug 1440855 - New font text-run highlighter used from the font inspector;r=gl This commit introduces a new highlighter. This highlighter is specialized in highlighting text runs in a page that use a specified font. The highlighter is based on a platform API that returns Range objects. Therefore, the approach I chose was to simply feed these object to the window Selection object. This way, we get highlighting for free without having to create any markup in the content page. The drawback is that the highlighting looks different than in other places of DevTools. However it's most probably way better in terms of performance, and will adapt natively to edge cases like APZ, scrolling, CSS transform, etc. This commit also has a simple UI: on mouseover of a font name in the font inspector panel, corresponding text runs are highlighted in the page. Finally, an integration was added. MozReview-Commit-ID: Gm74DtcdznN
devtools/client/inspector/fonts/actions/fonts.js
devtools/client/inspector/fonts/actions/index.js
devtools/client/inspector/fonts/components/Font.js
devtools/client/inspector/fonts/components/FontList.js
devtools/client/inspector/fonts/components/FontsApp.js
devtools/client/inspector/fonts/fonts.js
devtools/client/inspector/fonts/reducers/fonts.js
devtools/client/inspector/fonts/test/browser.ini
devtools/client/inspector/fonts/test/browser_fontinspector_reveal-in-page.js
devtools/client/inspector/fonts/test/head.js
devtools/server/actors/highlighters.js
devtools/server/actors/highlighters/fonts.js
devtools/server/actors/highlighters/moz.build
--- a/devtools/client/inspector/fonts/actions/fonts.js
+++ b/devtools/client/inspector/fonts/actions/fonts.js
@@ -1,21 +1,32 @@
 /* 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 {
+  UPDATE_FONT_HIGHLIGHTED,
   UPDATE_FONTS,
 } = require("./index");
 
 module.exports = {
 
   /**
+   * Set a given font as being currently revealed in the page
+   */
+  setRevealedFont(font) {
+    return {
+      type: UPDATE_FONT_HIGHLIGHTED,
+      font
+    };
+  },
+
+  /**
    * Update the list of fonts in the font inspector
    */
   updateFonts(fonts, otherFonts) {
     return {
       type: UPDATE_FONTS,
       fonts,
       otherFonts,
     };
--- a/devtools/client/inspector/fonts/actions/index.js
+++ b/devtools/client/inspector/fonts/actions/index.js
@@ -3,15 +3,18 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const { createEnum } = require("devtools/client/shared/enum");
 
 createEnum([
 
+  // Set a given font as being currently revealed in the page.
+  "UPDATE_FONT_HIGHLIGHTED",
+
   // Update the list of fonts.
   "UPDATE_FONTS",
 
   // Update the preview text.
   "UPDATE_PREVIEW_TEXT",
 
 ], module.exports);
--- a/devtools/client/inspector/fonts/components/Font.js
+++ b/devtools/client/inspector/fonts/components/Font.js
@@ -13,28 +13,32 @@ const FontPreview = createFactory(requir
 const { getStr } = require("../utils/l10n");
 const Types = require("../types");
 
 class Font extends PureComponent {
   static get propTypes() {
     return {
       font: PropTypes.shape(Types.font).isRequired,
       fontOptions: PropTypes.shape(Types.fontOptions).isRequired,
+      isForCurrentElement: PropTypes.boolean,
       onPreviewFonts: PropTypes.func.isRequired,
+      onToggleFontHighlight: PropTypes.func.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
 
     this.state = {
       isFontFaceRuleExpanded: false,
     };
 
     this.onFontFaceRuleToggle = this.onFontFaceRuleToggle.bind(this);
+    this.onNameMouseOver = this.onNameMouseOver.bind(this);
+    this.onNameMouseOut = this.onNameMouseOut.bind(this);
   }
 
   componentWillReceiveProps(newProps) {
     if (this.props.font.name === newProps.font.name) {
       return;
     }
 
     this.setState({
@@ -44,16 +48,36 @@ class Font extends PureComponent {
 
   onFontFaceRuleToggle(event) {
     this.setState({
       isFontFaceRuleExpanded: !this.state.isFontFaceRuleExpanded
     });
     event.stopPropagation();
   }
 
+  onNameMouseOver() {
+    let {
+      font,
+      isForCurrentElement,
+      onToggleFontHighlight,
+    } = this.props;
+
+    onToggleFontHighlight(font, true, isForCurrentElement);
+  }
+
+  onNameMouseOut() {
+    let {
+      font,
+      isForCurrentElement,
+      onToggleFontHighlight,
+    } = this.props;
+
+    onToggleFontHighlight(font, false, isForCurrentElement);
+  }
+
   renderFontCSSCode(rule, ruleText) {
     if (!rule) {
       return null;
     }
 
     // Cut the rule text in 3 parts: the selector, the declarations, the closing brace.
     // This way we can collapse the declarations by default and display an expander icon
     // to expand them again.
@@ -104,17 +128,19 @@ class Font extends PureComponent {
         format
       )
     );
   }
 
   renderFontName(name) {
     return dom.h1(
       {
-        className: "font-name"
+        className: "font-name",
+        onMouseOver: this.onNameMouseOver,
+        onMouseOut: this.onNameMouseOut,
       },
       name
     );
   }
 
   renderFontCSSCodeTwisty() {
     let { isFontFaceRuleExpanded } = this.state;
 
--- a/devtools/client/inspector/fonts/components/FontList.js
+++ b/devtools/client/inspector/fonts/components/FontList.js
@@ -12,34 +12,40 @@ const Font = createFactory(require("./Fo
 
 const Types = require("../types");
 
 class FontList extends PureComponent {
   static get propTypes() {
     return {
       fontOptions: PropTypes.shape(Types.fontOptions).isRequired,
       fonts: PropTypes.arrayOf(PropTypes.shape(Types.font)).isRequired,
+      isCurrentElementFonts: PropTypes.boolean,
       onPreviewFonts: PropTypes.func.isRequired,
+      onToggleFontHighlight: PropTypes.func.isRequired,
     };
   }
 
   render() {
     let {
       fonts,
       fontOptions,
-      onPreviewFonts
+      isCurrentElementFonts,
+      onPreviewFonts,
+      onToggleFontHighlight
     } = this.props;
 
     return dom.ul(
       {
         className: "fonts-list"
       },
       fonts.map((font, i) => Font({
         key: i,
         font,
         fontOptions,
+        isForCurrentElement: isCurrentElementFonts,
         onPreviewFonts,
+        onToggleFontHighlight,
       }))
     );
   }
 }
 
 module.exports = FontList;
--- a/devtools/client/inspector/fonts/components/FontsApp.js
+++ b/devtools/client/inspector/fonts/components/FontsApp.js
@@ -16,63 +16,70 @@ const { getStr } = require("../utils/l10
 const Types = require("../types");
 
 class FontsApp extends PureComponent {
   static get propTypes() {
     return {
       fontData: PropTypes.shape(Types.fontData).isRequired,
       fontOptions: PropTypes.shape(Types.fontOptions).isRequired,
       onPreviewFonts: PropTypes.func.isRequired,
+      onToggleFontHighlight: PropTypes.func.isRequired,
     };
   }
 
   renderElementFonts() {
     let {
       fontData,
       fontOptions,
       onPreviewFonts,
+      onToggleFontHighlight,
     } = this.props;
     let { fonts } = fontData;
 
     return fonts.length ?
       FontList({
         fonts,
         fontOptions,
-        onPreviewFonts
+        isCurrentElementFonts: true,
+        onPreviewFonts,
+        onToggleFontHighlight,
       })
       :
       dom.div(
         {
           className: "devtools-sidepanel-no-result"
         },
         getStr("fontinspector.noFontsOnSelectedElement")
       );
   }
 
   renderOtherFonts() {
     let {
       fontData,
+      fontOptions,
       onPreviewFonts,
-      fontOptions,
+      onToggleFontHighlight,
     } = this.props;
     let { otherFonts } = fontData;
 
     if (!otherFonts.length) {
       return null;
     }
 
     return Accordion({
       items: [
         {
           header: getStr("fontinspector.otherFontsInPageHeader"),
           component: FontList,
           componentProps: {
             fontOptions,
             fonts: otherFonts,
-            onPreviewFonts
+            isCurrentElementFonts: false,
+            onPreviewFonts,
+            onToggleFontHighlight,
           },
           opened: false
         }
       ]
     });
   }
 
   render() {
--- a/devtools/client/inspector/fonts/fonts.js
+++ b/devtools/client/inspector/fonts/fonts.js
@@ -12,42 +12,44 @@ const { createFactory, createElement } =
 const { Provider } = require("devtools/client/shared/vendor/react-redux");
 
 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 { setRevealedFont, updateFonts } = require("./actions/fonts");
 const { updatePreviewText } = require("./actions/font-options");
 
 class FontInspector {
   constructor(inspector, window) {
     this.document = window.document;
     this.inspector = inspector;
     this.pageStyle = this.inspector.pageStyle;
     this.store = this.inspector.store;
 
     this.update = this.update.bind(this);
 
     this.onNewNode = this.onNewNode.bind(this);
     this.onPreviewFonts = this.onPreviewFonts.bind(this);
+    this.onToggleFontHighlight = this.onToggleFontHighlight.bind(this);
     this.onThemeChanged = this.onThemeChanged.bind(this);
 
     this.init();
   }
 
   init() {
     if (!this.inspector) {
       return;
     }
 
     let fontsApp = FontsApp({
       onPreviewFonts: this.onPreviewFonts,
+      onToggleFontHighlight: this.onToggleFontHighlight,
     });
 
     let provider = createElement(Provider, {
       id: "fontinspector",
       key: "fontinspector",
       store: this.store,
       title: INSPECTOR_L10N.getStr("inspector.sidebar.fontInspectorTitle"),
     }, fontsApp);
@@ -146,16 +148,56 @@ class FontInspector {
    * Handler for change in preview input.
    */
   onPreviewFonts(value) {
     this.store.dispatch(updatePreviewText(value));
     this.update();
   }
 
   /**
+   * Reveal a font's usage in the page.
+   *
+   * @param  {String} font
+   *         The name of the font to be revealed in the page.
+   * @param  {Boolean} show
+   *         Whether or not to reveal the font.
+   * @param  {Boolean} isForCurrentElement
+   *         Whether or not to reveal the font for the current element selection.
+   */
+  async onToggleFontHighlight(font, show, isForCurrentElement) {
+    if (!this.fontsHighlighter) {
+      try {
+        this.fontsHighlighter = await this.inspector.toolbox.highlighterUtils
+                                          .getHighlighterByType("FontsHighlighter");
+      } catch (e) {
+        // When connecting to an older server or when debugging a XUL document, the
+        // FontsHighlighter won't be available. Silently fail here and prevent any future
+        // calls to the function.
+        this.onToggleFontHighlight = () => {};
+        return;
+      }
+    }
+
+    if (show) {
+      let node = isForCurrentElement
+                 ? this.inspector.selection.nodeFront
+                 : this.inspector.walker.rootNode;
+
+      await this.fontsHighlighter.show(node, {
+        CSSFamilyName: font.CSSFamilyName,
+        name: font.name,
+      });
+    } else {
+      await this.fontsHighlighter.hide();
+    }
+
+    this.store.dispatch(setRevealedFont(font));
+  }
+
+  /**
    * Handler for the "theme-switched" event.
    */
   onThemeChanged(event, frame) {
     if (frame === this.document.defaultView) {
       this.update();
     }
   }
 
--- a/devtools/client/inspector/fonts/reducers/fonts.js
+++ b/devtools/client/inspector/fonts/reducers/fonts.js
@@ -1,25 +1,53 @@
 /* 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 {
+  UPDATE_FONT_HIGHLIGHTED,
   UPDATE_FONTS,
 } = require("../actions/index");
 
 const INITIAL_FONT_DATA = {
   fonts: [],
   otherFonts: []
 };
 
 let reducers = {
 
+  [UPDATE_FONT_HIGHLIGHTED]({ fonts, otherFonts }, { font: revealedFont }) {
+    let newFonts = [];
+    let newOtherFonts = [];
+
+    for (let font of fonts) {
+      if (font === revealedFont) {
+        font = Object.assign({}, font, { isRevealed: !font.isRevealed });
+      } else if (font.isRevealed) {
+        font = Object.assign({}, font, { isRevealed: false });
+      }
+
+      newFonts.push(font);
+    }
+
+    for (let font of otherFonts) {
+      if (font === revealedFont) {
+        font = Object.assign({}, font, { isRevealed: !font.isRevealed });
+      } else if (font.isRevealed) {
+        font = Object.assign({}, font, { isRevealed: false });
+      }
+
+      newOtherFonts.push(font);
+    }
+
+    return { fonts: newFonts, otherFonts: newOtherFonts };
+  },
+
   [UPDATE_FONTS](_, { fonts, otherFonts }) {
     return { fonts, otherFonts };
   },
 
 };
 
 module.exports = function (fontData = INITIAL_FONT_DATA, action) {
   let reducer = reducers[action.type];
--- a/devtools/client/inspector/fonts/test/browser.ini
+++ b/devtools/client/inspector/fonts/test/browser.ini
@@ -13,10 +13,11 @@ support-files =
   !/devtools/client/inspector/test/shared-head.js
   !/devtools/client/shared/test/test-actor.js
   !/devtools/client/shared/test/test-actor-registry.js
 
 [browser_fontinspector.js]
 [browser_fontinspector_edit-previews.js]
 [browser_fontinspector_expand-css-code.js]
 [browser_fontinspector_other-fonts.js]
+[browser_fontinspector_reveal-in-page.js]
 [browser_fontinspector_text-node.js]
 [browser_fontinspector_theme-change.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_reveal-in-page.js
@@ -0,0 +1,57 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that fonts usage can be revealed in the page using the FontsHighlighter.
+
+const TEST_URI = URL_ROOT + "browser_fontinspector.html";
+
+add_task(function* () {
+  // Make sure the toolbox is tal enough to accomodate all fonts, otherwise mouseover
+  // events simulation will fail.
+  yield pushPref("devtools.toolbox.footer.height", 500);
+
+  let { tab, view } = yield openFontInspectorForURL(TEST_URI);
+  const viewDoc = view.document;
+
+  let fontEls = getUsedFontsEls(viewDoc);
+  let expectedSelectionChangeEvents = [1, 2, 2, 1, 1];
+
+  for (let i = 0; i < fontEls.length; i++) {
+    info(`Mousing over and out of font number ${i} in the list`);
+
+    // Simulating a mouse over event on the font name and expecting a selectionchange.
+    let nameEl = fontEls[i].querySelector(".font-name");
+    let onEvents = waitForNSelectionEvents(tab, expectedSelectionChangeEvents[i]);
+    EventUtils.synthesizeMouse(nameEl, 2, 2, {type: "mouseover"}, viewDoc.defaultView);
+    yield onEvents;
+    ok(true,
+      `${expectedSelectionChangeEvents[i]} selectionchange events detected on mouseover`);
+
+    // Simulating a mouse out event on the font name and expecting a selectionchange.
+    let otherEl = fontEls[i].querySelector(".theme-twisty");
+    onEvents = waitForNSelectionEvents(tab, 1);
+    EventUtils.synthesizeMouse(otherEl, 2, 2, {type: "mouseover"}, viewDoc.defaultView);
+    yield onEvents;
+    ok(true, "1 selectionchange events detected on mouseout");
+  }
+});
+
+function* waitForNSelectionEvents(tab, numberOfTimes) {
+  yield ContentTask.spawn(tab.linkedBrowser, numberOfTimes, async function (n) {
+    let win = content.wrappedJSObject;
+
+    await new Promise(resolve => {
+      let received = 0;
+      win.document.addEventListener("selectionchange", function listen() {
+        received++;
+
+        if (received === n) {
+          win.document.removeEventListener("selectionchange", listen);
+          resolve();
+        }
+      });
+    });
+  });
+}
--- a/devtools/client/inspector/fonts/test/head.js
+++ b/devtools/client/inspector/fonts/test/head.js
@@ -30,25 +30,26 @@ selectNode = function* (node, inspector,
 };
 
 /**
  * Adds a new tab with the given URL, opens the inspector and selects the
  * font-inspector tab.
  * @return {Promise} resolves to a {toolbox, inspector, view} object
  */
 var openFontInspectorForURL = Task.async(function* (url) {
-  yield addTab(url);
+  let tab = yield addTab(url);
   let {toolbox, inspector} = yield openInspector();
 
   // Call selectNode again here to force a fontinspector update since we don't
   // know if the fontinspector-updated event has been sent while the inspector
   // was being opened or not.
   yield selectNode("body", inspector);
 
   return {
+    tab,
     toolbox,
     inspector,
     view: inspector.fontinspector
   };
 });
 
 /**
  * Focus one of the preview inputs, clear it, type new text into it and wait for the
--- a/devtools/server/actors/highlighters.js
+++ b/devtools/server/actors/highlighters.js
@@ -706,14 +706,15 @@ HighlighterEnvironment.prototype = {
   }
 };
 
 register("BoxModelHighlighter", "box-model");
 register("CssGridHighlighter", "css-grid");
 register("CssTransformHighlighter", "css-transform");
 register("EyeDropper", "eye-dropper");
 register("FlexboxHighlighter", "flexbox");
+register("FontsHighlighter", "fonts");
 register("GeometryEditorHighlighter", "geometry-editor");
 register("MeasuringToolHighlighter", "measuring-tool");
 register("PausedDebuggerOverlay", "paused-debugger");
 register("RulersHighlighter", "rulers");
 register("SelectorHighlighter", "selector");
 register("ShapesHighlighter", "shapes");
new file mode 100644
--- /dev/null
+++ b/devtools/server/actors/highlighters/fonts.js
@@ -0,0 +1,88 @@
+ /* 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 InspectorUtils = require("InspectorUtils");
+
+// How many text runs are we highlighting at a time. There may be many text runs, and we
+// want to prevent performance problems.
+const MAX_TEXT_RANGES = 100;
+
+/**
+ * This highlighter highlights runs of text in the page that have been rendered given a
+ * certain font. The highlighting is done with window selection ranges, so no extra
+ * markup is being inserted into the content page.
+ */
+class FontsHighlighter {
+  constructor(highlighterEnv) {
+    this.env = highlighterEnv;
+  }
+
+  destroy() {
+    this.hide();
+    this.env = this.currentNode = null;
+  }
+
+  get currentNodeDocument() {
+    if (!this.currentNode) {
+      return this.env.document;
+    }
+
+    if (this.currentNode.nodeType === this.currentNode.DOCUMENT_NODE) {
+      return this.currentNode;
+    }
+
+    return this.currentNode.ownerDocument;
+  }
+
+  /**
+   * Show the highlighter for a given node.
+   * @param {DOMNode} node The node in which we want to search for text runs.
+   * @param {Object} options A bunch of options that can be set:
+   * - {String} name The actual font name to look for in the node.
+   * - {String} CSSFamilyName The CSS font-family name given to this font.
+   */
+  show(node, options) {
+    this.currentNode = node;
+    let doc = this.currentNodeDocument;
+
+    // Get all of the fonts used to render content inside the node.
+    let searchRange = doc.createRange();
+    searchRange.selectNodeContents(node);
+
+    let fonts = InspectorUtils.getUsedFontFaces(searchRange, MAX_TEXT_RANGES);
+
+    // Find the ones we want, based on the provided option.
+    let matchingFonts = fonts.filter(f => f.CSSFamilyName === options.CSSFamilyName &&
+                                          f.name === options.name);
+    if (!matchingFonts.length) {
+      return;
+    }
+
+    // Create a multi-selection in the page to highlight the text runs.
+    let selection = doc.defaultView.getSelection();
+    selection.removeAllRanges();
+
+    for (let matchingFont of matchingFonts) {
+      for (let range of matchingFont.ranges) {
+        selection.addRange(range);
+      }
+    }
+  }
+
+  hide() {
+    // No node was highlighted before, don't need to continue any further.
+    if (!this.currentNode) {
+      return;
+    }
+
+    // Simply remove all current ranges in the seletion.
+    let doc = this.currentNodeDocument;
+    let selection = doc.defaultView.getSelection();
+    selection.removeAllRanges();
+  }
+}
+
+exports.FontsHighlighter = FontsHighlighter;
--- a/devtools/server/actors/highlighters/moz.build
+++ b/devtools/server/actors/highlighters/moz.build
@@ -11,16 +11,17 @@ DIRS += [
 DevToolsModules(
     'accessible.js',
     'auto-refresh.js',
     'box-model.js',
     'css-grid.js',
     'css-transform.js',
     'eye-dropper.js',
     'flexbox.js',
+    'fonts.js',
     'geometry-editor.js',
     'measuring-tool.js',
     'paused-debugger.js',
     'rulers.js',
     'selector.js',
     'shapes.js',
     'simple-outline.js',
     'xul-accessible.js'