tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

fonts.js (36057B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const {
      8  gDevTools,
      9 } = require("resource://devtools/client/framework/devtools.js");
     10 const {
     11  getCssVariableColor,
     12 } = require("resource://devtools/client/shared/theme.js");
     13 const {
     14  createFactory,
     15  createElement,
     16 } = require("resource://devtools/client/shared/vendor/react.mjs");
     17 const {
     18  Provider,
     19 } = require("resource://devtools/client/shared/vendor/react-redux.js");
     20 const { debounce } = require("resource://devtools/shared/debounce.js");
     21 const {
     22  style: { ELEMENT_STYLE },
     23 } = require("resource://devtools/shared/constants.js");
     24 
     25 const FontsApp = createFactory(
     26  require("resource://devtools/client/inspector/fonts/components/FontsApp.js")
     27 );
     28 
     29 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
     30 const INSPECTOR_L10N = new LocalizationHelper(
     31  "devtools/client/locales/inspector.properties"
     32 );
     33 
     34 const {
     35  parseFontVariationAxes,
     36 } = require("resource://devtools/client/inspector/fonts/utils/font-utils.js");
     37 
     38 const fontDataReducer = require("resource://devtools/client/inspector/fonts/reducers/fonts.js");
     39 const fontEditorReducer = require("resource://devtools/client/inspector/fonts/reducers/font-editor.js");
     40 const fontOptionsReducer = require("resource://devtools/client/inspector/fonts/reducers/font-options.js");
     41 const {
     42  updateFonts,
     43 } = require("resource://devtools/client/inspector/fonts/actions/fonts.js");
     44 const {
     45  applyInstance,
     46  resetFontEditor,
     47  setEditorDisabled,
     48  updateAxis,
     49  updateFontEditor,
     50  updateFontProperty,
     51 } = require("resource://devtools/client/inspector/fonts/actions/font-editor.js");
     52 const {
     53  updatePreviewText,
     54 } = require("resource://devtools/client/inspector/fonts/actions/font-options.js");
     55 const { TYPES: HIGHLIGHTER_TYPES } = ChromeUtils.importESModule(
     56  "resource://devtools/shared/highlighters.mjs"
     57 );
     58 
     59 const FONT_PROPERTIES = [
     60  "font-family",
     61  "font-optical-sizing",
     62  "font-size",
     63  "font-stretch",
     64  "font-style",
     65  "font-variation-settings",
     66  "font-weight",
     67  "letter-spacing",
     68  "line-height",
     69 ];
     70 const REGISTERED_AXES_TO_FONT_PROPERTIES = {
     71  ital: "font-style",
     72  opsz: "font-optical-sizing",
     73  slnt: "font-style",
     74  wdth: "font-stretch",
     75  wght: "font-weight",
     76 };
     77 const REGISTERED_AXES = Object.keys(REGISTERED_AXES_TO_FONT_PROPERTIES);
     78 
     79 class FontInspector {
     80  constructor(inspector, window) {
     81    this.cssProperties = inspector.cssProperties;
     82    this.document = window.document;
     83    this.inspector = inspector;
     84    // Selected node in the markup view. For text nodes, this points to their parent node
     85    // element. Font faces and font properties for this node will be shown in the editor.
     86    this.node = null;
     87    this.nodeComputedStyle = {};
     88    // The page style actor that will be providing the style information.
     89    this.pageStyle = null;
     90    this.ruleViewTool = this.inspector.getPanel("ruleview");
     91    this.ruleView = this.ruleViewTool.view;
     92    this.selectedRule = null;
     93    this.store = this.inspector.store;
     94    // Map CSS property names and variable font axis names to methods that write their
     95    // corresponding values to the appropriate TextProperty from the Rule view.
     96    // Values of variable font registered axes may be written to CSS font properties under
     97    // certain cascade circumstances and platform support. @see `getWriterForAxis(axis)`
     98    this.writers = new Map();
     99 
    100    this.store.injectReducer("fontOptions", fontOptionsReducer);
    101    this.store.injectReducer("fontData", fontDataReducer);
    102    this.store.injectReducer("fontEditor", fontEditorReducer);
    103 
    104    this.syncChanges = debounce(this.syncChanges, 100, this);
    105    this.onInstanceChange = this.onInstanceChange.bind(this);
    106    this.onNewNode = this.onNewNode.bind(this);
    107    this.onPreviewTextChange = debounce(this.onPreviewTextChange, 100, this);
    108    this.onPropertyChange = this.onPropertyChange.bind(this);
    109    this.onRulePropertyUpdated = debounce(
    110      this.onRulePropertyUpdated,
    111      300,
    112      this
    113    );
    114    this.onToggleFontHighlight = this.onToggleFontHighlight.bind(this);
    115    this.onThemeChanged = this.onThemeChanged.bind(this);
    116    this.update = this.update.bind(this);
    117    this.updateFontVariationSettings =
    118      this.updateFontVariationSettings.bind(this);
    119 
    120    this.init();
    121  }
    122 
    123  /**
    124   * Map CSS font property names to a list of values that should be skipped when consuming
    125   * font properties from CSS rules. The skipped values are mostly keyword values like
    126   * `bold`, `initial`, `unset`. Computed values will be used instead of such keywords.
    127   *
    128   * @return {Map}
    129   */
    130  get skipValuesMap() {
    131    if (!this._skipValuesMap) {
    132      this._skipValuesMap = new Map();
    133 
    134      for (const property of FONT_PROPERTIES) {
    135        const values = this.cssProperties.getValues(property);
    136 
    137        switch (property) {
    138          case "line-height":
    139          case "letter-spacing":
    140            // There's special handling for "normal" so remove it from the skip list.
    141            this.skipValuesMap.set(
    142              property,
    143              values.filter(value => value !== "normal")
    144            );
    145            break;
    146          default:
    147            this.skipValuesMap.set(property, values);
    148        }
    149      }
    150    }
    151 
    152    return this._skipValuesMap;
    153  }
    154 
    155  init() {
    156    if (!this.inspector) {
    157      return;
    158    }
    159 
    160    const fontsApp = FontsApp({
    161      onInstanceChange: this.onInstanceChange,
    162      onToggleFontHighlight: this.onToggleFontHighlight,
    163      onPreviewTextChange: this.onPreviewTextChange,
    164      onPropertyChange: this.onPropertyChange,
    165    });
    166 
    167    const provider = createElement(
    168      Provider,
    169      {
    170        id: "fontinspector",
    171        key: "fontinspector",
    172        store: this.store,
    173        title: INSPECTOR_L10N.getStr("inspector.sidebar.fontInspectorTitle"),
    174      },
    175      fontsApp
    176    );
    177 
    178    // Expose the provider to let inspector.js use it in setupSidebar.
    179    this.provider = provider;
    180 
    181    this.inspector.selection.on("new-node-front", this.onNewNode);
    182    // @see ToolSidebar.onSidebarTabSelected()
    183    this.inspector.sidebar.on("fontinspector-selected", this.onNewNode);
    184 
    185    // Listen for theme changes as the color of the previews depend on the theme
    186    gDevTools.on("theme-switched", this.onThemeChanged);
    187  }
    188 
    189  /**
    190   * Convert a value for font-size between two CSS unit types.
    191   * Conversion is done via pixels. If neither of the two given unit types is "px",
    192   * recursively get the value in pixels, then convert that result to the desired unit.
    193   *
    194   * @param  {string} property
    195   *         Property name for the converted value.
    196   *         Assumed to be "font-size", but special case for "line-height".
    197   * @param  {number} value
    198   *         Numeric value to convert.
    199   * @param  {string} fromUnit
    200   *         CSS unit to convert from.
    201   * @param  {string} toUnit
    202   *         CSS unit to convert to.
    203   * @return {number}
    204   *         Converted numeric value.
    205   */
    206  async convertUnits(property, value, fromUnit, toUnit) {
    207    if (value !== parseFloat(value)) {
    208      throw TypeError(
    209        `Invalid value for conversion. Expected Number, got ${value}`
    210      );
    211    }
    212 
    213    const shouldReturn = () => {
    214      // Early return if:
    215      // - conversion is not required
    216      // - property is `line-height`
    217      // - `fromUnit` is `em` and `toUnit` is unitless
    218      const conversionNotRequired = fromUnit === toUnit || value === 0;
    219      const forLineHeight =
    220        property === "line-height" && fromUnit === "" && toUnit === "em";
    221      const isEmToUnitlessConversion = fromUnit === "em" && toUnit === "";
    222      return conversionNotRequired || forLineHeight || isEmToUnitlessConversion;
    223    };
    224 
    225    if (shouldReturn()) {
    226      return value;
    227    }
    228 
    229    // If neither unit is in pixels, first convert the value to pixels.
    230    // Reassign input value and source CSS unit.
    231    if (toUnit !== "px" && fromUnit !== "px") {
    232      value = await this.convertUnits(property, value, fromUnit, "px");
    233      fromUnit = "px";
    234    }
    235 
    236    // Whether the conversion is done from pixels.
    237    const fromPx = fromUnit === "px";
    238    // Determine the target CSS unit for conversion.
    239    const unit = toUnit === "px" ? fromUnit : toUnit;
    240    // Default output value to input value for a 1-to-1 conversion as a guard against
    241    // unrecognized CSS units. It will not be correct, but it will also not break.
    242    let out = value;
    243 
    244    const converters = {
    245      in: () => (fromPx ? value / 96 : value * 96),
    246      cm: () => (fromPx ? value * 0.02645833333 : value / 0.02645833333),
    247      mm: () => (fromPx ? value * 0.26458333333 : value / 0.26458333333),
    248      pt: () => (fromPx ? value * 0.75 : value / 0.75),
    249      pc: () => (fromPx ? value * 0.0625 : value / 0.0625),
    250      "%": async () => {
    251        const fontSize = await this.getReferenceFontSize(property, unit);
    252        return fromPx
    253          ? (value * 100) / parseFloat(fontSize)
    254          : (value / 100) * parseFloat(fontSize);
    255      },
    256      rem: async () => {
    257        const fontSize = await this.getReferenceFontSize(property, unit);
    258        return fromPx
    259          ? value / parseFloat(fontSize)
    260          : value * parseFloat(fontSize);
    261      },
    262      vh: async () => {
    263        const { height } = await this.getReferenceBox(property, unit);
    264        return fromPx ? (value * 100) / height : (value / 100) * height;
    265      },
    266      vw: async () => {
    267        const { width } = await this.getReferenceBox(property, unit);
    268        return fromPx ? (value * 100) / width : (value / 100) * width;
    269      },
    270      vmin: async () => {
    271        const { width, height } = await this.getReferenceBox(property, unit);
    272        return fromPx
    273          ? (value * 100) / Math.min(width, height)
    274          : (value / 100) * Math.min(width, height);
    275      },
    276      vmax: async () => {
    277        const { width, height } = await this.getReferenceBox(property, unit);
    278        return fromPx
    279          ? (value * 100) / Math.max(width, height)
    280          : (value / 100) * Math.max(width, height);
    281      },
    282    };
    283 
    284    if (converters.hasOwnProperty(unit)) {
    285      const converter = converters[unit];
    286      out = await converter();
    287    }
    288 
    289    // Special handling for unitless line-height.
    290    if (unit === "em" || (unit === "" && property === "line-height")) {
    291      const fontSize = await this.getReferenceFontSize(property, unit);
    292      out = fromPx
    293        ? value / parseFloat(fontSize)
    294        : value * parseFloat(fontSize);
    295    }
    296 
    297    // Catch any NaN or Infinity as result of dividing by zero in any
    298    // of the relative unit conversions which rely on external values.
    299    if (isNaN(out) || Math.abs(out) === Infinity) {
    300      out = 0;
    301    }
    302 
    303    // Return values limited to 3 decimals when:
    304    // - the unit is converted from pixels to something else
    305    // - the value is for letter spacing, regardless of unit (allow sub-pixel precision)
    306    if (fromPx || property === "letter-spacing") {
    307      // Round values like 1.000 to 1
    308      return out === Math.round(out) ? Math.round(out) : out.toFixed(3);
    309    }
    310 
    311    // Round pixel values.
    312    return Math.round(out);
    313  }
    314 
    315  /**
    316   * Destruction function called when the inspector is destroyed. Removes event listeners
    317   * and cleans up references.
    318   */
    319  destroy() {
    320    this.inspector.selection.off("new-node-front", this.onNewNode);
    321    this.inspector.sidebar.off("fontinspector-selected", this.onNewNode);
    322    this.ruleView.off("property-value-updated", this.onRulePropertyUpdated);
    323    gDevTools.off("theme-switched", this.onThemeChanged);
    324 
    325    this.document = null;
    326    this.inspector = null;
    327    this.node = null;
    328    this.nodeComputedStyle = {};
    329    this.pageStyle = null;
    330    this.ruleView = null;
    331    this.selectedRule = null;
    332    this.store = null;
    333    this.writers.clear();
    334    this.writers = null;
    335  }
    336 
    337  /**
    338   * Get all expected CSS font properties and values from the node's matching rules and
    339   * fallback to computed style. Skip CSS Custom Properties, `calc()` and keyword values.
    340   *
    341   * @return {object}
    342   */
    343  async getFontProperties() {
    344    const properties = {};
    345 
    346    // First, get all expected font properties from computed styles, if available.
    347    for (const prop of FONT_PROPERTIES) {
    348      properties[prop] =
    349        this.nodeComputedStyle[prop] && this.nodeComputedStyle[prop].value
    350          ? this.nodeComputedStyle[prop].value
    351          : "";
    352    }
    353 
    354    // Then, replace with enabled font properties found on any of the rules that apply.
    355    for (const rule of this.ruleView.rules) {
    356      if (rule.inherited) {
    357        continue;
    358      }
    359 
    360      for (const textProp of rule.textProps) {
    361        if (
    362          FONT_PROPERTIES.includes(textProp.name) &&
    363          !this.skipValuesMap.get(textProp.name).includes(textProp.value) &&
    364          !textProp.value.includes("calc(") &&
    365          !textProp.value.includes("var(") &&
    366          !textProp.overridden &&
    367          textProp.enabled
    368        ) {
    369          properties[textProp.name] = textProp.value;
    370        }
    371      }
    372    }
    373 
    374    return properties;
    375  }
    376 
    377  async getFontsForNode(node, options) {
    378    // In case we've been destroyed in the meantime
    379    if (!this.document) {
    380      return [];
    381    }
    382 
    383    const fonts = await this.pageStyle
    384      .getUsedFontFaces(node, options)
    385      .catch(console.error);
    386    if (!fonts) {
    387      return [];
    388    }
    389 
    390    return fonts;
    391  }
    392 
    393  async getAllFonts(options) {
    394    // In case we've been destroyed in the meantime
    395    if (!this.document) {
    396      return [];
    397    }
    398 
    399    const inspectorFronts = await this.inspector.getAllInspectorFronts();
    400 
    401    let allFonts = [];
    402    for (const { pageStyle } of inspectorFronts) {
    403      allFonts = allFonts.concat(await pageStyle.getAllUsedFontFaces(options));
    404    }
    405 
    406    return allFonts;
    407  }
    408 
    409  /**
    410   * Get the box dimensions used for unit conversion according to the CSS property and
    411   * target CSS unit.
    412   *
    413   * @param  {string} property
    414   *         CSS property
    415   * @param  {string} unit
    416   *         Target CSS unit
    417   * @return {Promise}
    418   *         Promise that resolves with an object with box dimensions in pixels.
    419   */
    420  async getReferenceBox(property, unit) {
    421    const box = { width: 0, height: 0 };
    422    const node = await this.getReferenceNode(property, unit).catch(
    423      console.error
    424    );
    425 
    426    if (!node) {
    427      return box;
    428    }
    429 
    430    switch (unit) {
    431      case "vh":
    432      case "vw":
    433      case "vmin":
    434      case "vmax": {
    435        const dim = await node.getOwnerGlobalDimensions().catch(console.error);
    436        if (dim) {
    437          box.width = dim.innerWidth;
    438          box.height = dim.innerHeight;
    439        }
    440        break;
    441      }
    442      case "%": {
    443        const style = await this.pageStyle
    444          .getComputed(node)
    445          .catch(console.error);
    446        if (style) {
    447          box.width = style.width.value;
    448          box.height = style.height.value;
    449        }
    450        break;
    451      }
    452    }
    453 
    454    return box;
    455  }
    456 
    457  /**
    458   * Get the refernece font size value used for unit conversion according to the
    459   * CSS property and target CSS unit.
    460   *
    461   * @param {string} property
    462   *        CSS property
    463   * @param {string} unit
    464   *        Target CSS unit
    465   * @return {Promise}
    466   *         Promise that resolves with the reference font size value or null if there
    467   *         was an error getting that value.
    468   */
    469  async getReferenceFontSize(property, unit) {
    470    const node = await this.getReferenceNode(property, unit).catch(
    471      console.error
    472    );
    473    if (!node) {
    474      return null;
    475    }
    476 
    477    const style = await this.pageStyle.getComputed(node).catch(console.error);
    478    if (!style) {
    479      return null;
    480    }
    481 
    482    return style["font-size"].value;
    483  }
    484 
    485  /**
    486   * Get the reference node used in measurements for unit conversion according to the
    487   * the CSS property and target CSS unit type.
    488   *
    489   * @param  {string} property
    490   *         CSS property
    491   * @param  {string} unit
    492   *         Target CSS unit
    493   * @return {Promise}
    494   *          Promise that resolves with the reference node used in measurements for unit
    495   *          conversion.
    496   */
    497  async getReferenceNode(property, unit) {
    498    let node;
    499 
    500    switch (property) {
    501      case "line-height":
    502      case "letter-spacing":
    503        node = this.node;
    504        break;
    505      default:
    506        node = this.node.parentNode();
    507    }
    508 
    509    switch (unit) {
    510      case "rem":
    511        // Regardless of CSS property, always use the root document element for "rem".
    512        node = await this.node.walkerFront.documentElement();
    513        break;
    514    }
    515 
    516    return node;
    517  }
    518 
    519  /**
    520   * Get a reference to a TextProperty instance from the current selected rule for a
    521   * given property name.
    522   *
    523   * @param {string} name
    524   *        CSS property name
    525   * @return {TextProperty|null}
    526   */
    527  getTextProperty(name) {
    528    if (!this.selectedRule) {
    529      return null;
    530    }
    531 
    532    return this.selectedRule.textProps.find(
    533      prop => prop.name === name && prop.enabled && !prop.overridden
    534    );
    535  }
    536 
    537  /**
    538   * Given the axis name of a registered axis, return a method which updates the
    539   * corresponding CSS font property when called with a value.
    540   *
    541   * All variable font axes can be written in the value of the "font-variation-settings"
    542   * CSS font property. In CSS Fonts Level 4, registered axes values can be used as
    543   * values of font properties, like "font-weight", "font-stretch" and "font-style".
    544   *
    545   * Axes declared in "font-variation-settings", either on the rule or inherited,
    546   * overwrite any corresponding font properties. Updates to these axes must be written
    547   * to "font-variation-settings" to preserve the cascade. Authors are discouraged from
    548   * using this practice. Whenever possible, registered axes values should be written to
    549   * their corresponding font properties.
    550   *
    551   * Registered axis name to font property mapping:
    552   *  - wdth -> font-stretch
    553   *  - wght -> font-weight
    554   *  - opsz -> font-optical-sizing
    555   *  - slnt -> font-style
    556   *  - ital -> font-style
    557   *
    558   * @param {string} axis
    559   *        Name of registered axis.
    560   * @return {Function}
    561   *         Method to call which updates the corresponding CSS font property.
    562   */
    563  getWriterForAxis(axis) {
    564    // Find any declaration of "font-variation-setttings".
    565    const FVSComputedStyle = this.nodeComputedStyle["font-variation-settings"];
    566 
    567    // If "font-variation-settings" CSS property is defined (on the rule or inherited)
    568    // and contains a declaration for the given registered axis, write to it.
    569    if (FVSComputedStyle && FVSComputedStyle.value.includes(axis)) {
    570      return this.updateFontVariationSettings;
    571    }
    572 
    573    // Get corresponding CSS font property value for registered axis.
    574    const property = REGISTERED_AXES_TO_FONT_PROPERTIES[axis];
    575 
    576    return value => {
    577      let condition = false;
    578 
    579      switch (axis) {
    580        case "wght":
    581          // Whether the page supports values of font-weight from CSS Fonts Level 4.
    582          condition = this.pageStyle.supportsFontWeightLevel4;
    583          break;
    584 
    585        case "wdth":
    586          // font-stretch in CSS Fonts Level 4 accepts percentage units.
    587          value = `${value}%`;
    588          // Whether the page supports values of font-stretch from CSS Fonts Level 4.
    589          condition = this.pageStyle.supportsFontStretchLevel4;
    590          break;
    591 
    592        case "slnt":
    593          // font-style in CSS Fonts Level 4 accepts an angle value.
    594          // We have to invert the sign of the angle because CSS and OpenType measure
    595          // in opposite directions.
    596          value = -value;
    597          value = `oblique ${value}deg`;
    598          // Whether the page supports values of font-style from CSS Fonts Level 4.
    599          condition = this.pageStyle.supportsFontStyleLevel4;
    600          break;
    601      }
    602 
    603      if (condition) {
    604        this.updatePropertyValue(property, value);
    605      } else {
    606        // Replace the writer method for this axis so it won't get called next time.
    607        this.writers.set(axis, this.updateFontVariationSettings);
    608        // Fall back to writing to font-variation-settings together with all other axes.
    609        this.updateFontVariationSettings();
    610      }
    611    };
    612  }
    613 
    614  /**
    615   * Given a CSS property name or axis name of a variable font, return a method which
    616   * updates the corresponding CSS font property when called with a value.
    617   *
    618   * This is used to distinguish between CSS font properties, registered axes and
    619   * custom axes. Registered axes, like "wght" and "wdth", should be written to
    620   * corresponding CSS properties, like "font-weight" and "font-stretch".
    621   *
    622   * Unrecognized names (which aren't font property names or registered axes names) are
    623   * considered to be custom axes names and will be written to the
    624   * "font-variation-settings" CSS property.
    625   *
    626   * @param {string} name
    627   *        CSS property name or axis name.
    628   * @return {Function}
    629   *         Method which updates the rule view and page style.
    630   */
    631  getWriterForProperty(name) {
    632    if (this.writers.has(name)) {
    633      return this.writers.get(name);
    634    }
    635 
    636    if (REGISTERED_AXES.includes(name)) {
    637      this.writers.set(name, this.getWriterForAxis(name));
    638    } else if (FONT_PROPERTIES.includes(name)) {
    639      this.writers.set(name, value => {
    640        this.updatePropertyValue(name, value);
    641      });
    642    } else {
    643      this.writers.set(name, this.updateFontVariationSettings);
    644    }
    645 
    646    return this.writers.get(name);
    647  }
    648 
    649  /**
    650   * Check if the font inspector panel is visible.
    651   *
    652   * @return {boolean}
    653   */
    654  isPanelVisible() {
    655    return (
    656      this.inspector &&
    657      this.inspector.sidebar &&
    658      this.inspector.sidebar.getCurrentTabID() === "fontinspector"
    659    );
    660  }
    661 
    662  /**
    663   * Upon a new node selection, log some interesting telemetry probes.
    664   */
    665  logTelemetryProbesOnNewNode() {
    666    const { fontEditor } = this.store.getState();
    667 
    668    // Log data about the currently edited font (if any).
    669    // Note that the edited font is always the first one from the fontEditor.fonts array.
    670    const editedFont = fontEditor.fonts[0];
    671    if (!editedFont) {
    672      return;
    673    }
    674 
    675    const nbOfAxes = editedFont.variationAxes
    676      ? editedFont.variationAxes.length
    677      : 0;
    678    Glean.devtoolsInspector.fonteditorFontTypeDisplayed[
    679      !nbOfAxes ? "nonvariable" : "variable"
    680    ].add(1);
    681  }
    682 
    683  /**
    684   * Sync the Rule view with the latest styles from the page. Called in a debounced way
    685   * (see constructor) after property changes are applied directly to the CSS style rule
    686   * on the page circumventing direct TextProperty.setValue() which triggers expensive DOM
    687   * operations in TextPropertyEditor.update().
    688   *
    689   * @param  {string} name
    690   *         CSS property name
    691   * @param  {string} value
    692   *         CSS property value
    693   */
    694  async syncChanges(name, value) {
    695    const textProperty = this.getTextProperty(name, value);
    696    if (textProperty) {
    697      try {
    698        await textProperty.setValue(value, "", true);
    699        this.ruleView.on("property-value-updated", this.onRulePropertyUpdated);
    700      } catch (error) {
    701        // Because setValue() does an asynchronous call to the server, there is a chance
    702        // the font editor was destroyed while we were waiting. If that happened, just
    703        // bail out silently.
    704        if (!this.document) {
    705          return;
    706        }
    707 
    708        throw error;
    709      }
    710    }
    711  }
    712 
    713  /**
    714   * Handler for changes of a font axis value coming from the FontEditor.
    715   *
    716   * @param  {string} tag
    717   *         Tag name of the font axis.
    718   * @param  {number} value
    719   *         Value of the font axis.
    720   */
    721  onAxisUpdate(tag, value) {
    722    this.store.dispatch(updateAxis(tag, value));
    723    const writer = this.getWriterForProperty(tag);
    724    writer(value.toString());
    725  }
    726 
    727  /**
    728   * Handler for changes of a CSS font property value coming from the FontEditor.
    729   *
    730   * @param  {string} property
    731   *         CSS font property name.
    732   * @param  {number} value
    733   *         CSS font property numeric value.
    734   * @param  {string | null} unit
    735   *         CSS unit or null
    736   */
    737  onFontPropertyUpdate(property, value, unit) {
    738    value = unit !== null ? value + unit : value;
    739    this.store.dispatch(updateFontProperty(property, value));
    740    const writer = this.getWriterForProperty(property);
    741    writer(value.toString());
    742  }
    743 
    744  /**
    745   * Handler for selecting a font variation instance. Dispatches an action which updates
    746   * the axes and their values as defined by that variation instance.
    747   *
    748   * @param {string} name
    749   *        Name of variation instance. (ex: Light, Regular, Ultrabold, etc.)
    750   * @param {Array} values
    751   *        Array of objects with axes and values defined by the variation instance.
    752   */
    753  onInstanceChange(name, values) {
    754    this.store.dispatch(applyInstance(name, values));
    755    let writer;
    756    values.map(obj => {
    757      writer = this.getWriterForProperty(obj.axis);
    758      writer(obj.value.toString());
    759    });
    760  }
    761 
    762  /**
    763   * Event handler for "new-node-front" event fired when a new node is selected in the
    764   * markup view.
    765   *
    766   * Sets the selected node for which font faces and font properties will be
    767   * shown in the font editor. If the selection is a text node, use its parent element.
    768   *
    769   * Triggers a refresh of the font editor and font overview if the panel is visible.
    770   */
    771  onNewNode() {
    772    this.ruleView.off("property-value-updated", this.onRulePropertyUpdated);
    773 
    774    // First, reset the selected node and page style front.
    775    this.node = null;
    776    this.pageStyle = null;
    777 
    778    // Then attempt to assign a selected node according to its type.
    779    const selection = this.inspector && this.inspector.selection;
    780    if (selection && selection.isConnected()) {
    781      if (selection.isElementNode()) {
    782        this.node = selection.nodeFront;
    783      } else if (selection.isTextNode()) {
    784        this.node = selection.nodeFront.parentNode();
    785      }
    786 
    787      this.pageStyle = this.node.inspectorFront.pageStyle;
    788    }
    789 
    790    if (this.isPanelVisible()) {
    791      Promise.all([this.update(), this.refreshFontEditor()])
    792        .then(() => {
    793          this.logTelemetryProbesOnNewNode();
    794        })
    795        .catch(e => console.error(e));
    796    }
    797  }
    798 
    799  /**
    800   * Handler for change in preview input.
    801   */
    802  onPreviewTextChange(value) {
    803    this.store.dispatch(updatePreviewText(value));
    804    this.update();
    805  }
    806 
    807  /**
    808   * Handler for changes to any CSS font property value or variable font axis value coming
    809   * from the Font Editor. This handler calls the appropriate method to preview the
    810   * changes on the page and update the store.
    811   *
    812   * If the property parameter is not a recognized CSS font property name, assume it's a
    813   * variable font axis name.
    814   *
    815   * @param  {string} property
    816   *         CSS font property name or axis name
    817   * @param  {number} value
    818   *         CSS font property value or axis value
    819   * @param  {string | undefined} fromUnit
    820   *         Optional CSS unit to convert from
    821   * @param  {string | undefined} toUnit
    822   *         Optional CSS unit to convert to
    823   */
    824  async onPropertyChange(property, value, fromUnit, toUnit) {
    825    if (FONT_PROPERTIES.includes(property)) {
    826      let unit = fromUnit;
    827 
    828      // Strict checks because "line-height" value may be unitless (empty string).
    829      if (toUnit !== undefined && fromUnit !== undefined) {
    830        value = await this.convertUnits(property, value, fromUnit, toUnit);
    831        unit = toUnit;
    832      }
    833 
    834      this.onFontPropertyUpdate(property, value, unit);
    835    } else {
    836      this.onAxisUpdate(property, value);
    837    }
    838  }
    839 
    840  /**
    841   * Handler for "property-value-updated" event emitted from the rule view whenever a
    842   * property value changes. Ignore changes to properties unrelated to the font editor.
    843   *
    844   * @param {object} eventData
    845   *        Object with the property name and value and origin rule.
    846   *        Example: { name: "font-size", value: "1em", rule: Object }
    847   */
    848  async onRulePropertyUpdated(eventData) {
    849    if (!this.selectedRule || !FONT_PROPERTIES.includes(eventData.property)) {
    850      return;
    851    }
    852 
    853    if (this.isPanelVisible()) {
    854      await this.refreshFontEditor();
    855    }
    856  }
    857 
    858  /**
    859   * Reveal a font's usage in the page.
    860   *
    861   * @param  {string} font
    862   *         The name of the font to be revealed in the page.
    863   * @param  {boolean} show
    864   *         Whether or not to reveal the font.
    865   * @param  {boolean} isForCurrentElement
    866   *         Optional. Default `true`. Whether or not to restrict revealing the font
    867   *         just to the current element selection.
    868   */
    869  async onToggleFontHighlight(font, show, isForCurrentElement = true) {
    870    try {
    871      if (show) {
    872        const node = isForCurrentElement
    873          ? this.inspector.selection.nodeFront
    874          : this.node.walkerFront.rootNode;
    875 
    876        await this.inspector.highlighters.showHighlighterTypeForNode(
    877          HIGHLIGHTER_TYPES.FONTS,
    878          node,
    879          {
    880            CSSFamilyName: font.CSSFamilyName,
    881            name: font.name,
    882          }
    883        );
    884      } else {
    885        await this.inspector.highlighters.hideHighlighterType(
    886          HIGHLIGHTER_TYPES.FONTS
    887        );
    888      }
    889    } catch (e) {
    890      // Silently handle protocol errors here, because these might be called during
    891      // shutdown of the browser or devtools, and we don't care if they fail.
    892    }
    893  }
    894 
    895  /**
    896   * Handler for the "theme-switched" event.
    897   */
    898  onThemeChanged(frame) {
    899    if (frame === this.document.defaultView) {
    900      this.update();
    901    }
    902  }
    903 
    904  /**
    905   * Update the state of the font editor with:
    906   * - the fonts which apply to the current node;
    907   * - the computed style CSS font properties of the current node.
    908   *
    909   * This method is called:
    910   * - when a new node is selected;
    911   * - when any property is changed in the Rule view.
    912   * For the latter case, we compare between the latest computed style font properties
    913   * and the ones already in the store to decide if to update the font editor state.
    914   */
    915  async refreshFontEditor() {
    916    if (!this.node) {
    917      this.store.dispatch(resetFontEditor());
    918      return;
    919    }
    920 
    921    const options = {};
    922    if (this.pageStyle.supportsFontVariations) {
    923      options.includeVariations = true;
    924    }
    925 
    926    const fonts = await this.getFontsForNode(this.node, options);
    927 
    928    try {
    929      // Get computed styles for the selected node, but filter by CSS font properties.
    930      this.nodeComputedStyle = await this.pageStyle.getComputed(this.node, {
    931        filterProperties: FONT_PROPERTIES,
    932      });
    933    } catch (e) {
    934      // Because getComputed is async, there is a chance the font editor was
    935      // destroyed while we were waiting. If that happened, just bail out
    936      // silently.
    937      if (!this.document) {
    938        return;
    939      }
    940 
    941      throw e;
    942    }
    943 
    944    if (!this.nodeComputedStyle || !fonts.length) {
    945      this.store.dispatch(resetFontEditor());
    946      this.inspector.emit("fonteditor-updated");
    947      return;
    948    }
    949 
    950    // Clear any references to writer methods and CSS declarations because the node's
    951    // styles may have changed since the last font editor refresh.
    952    this.writers.clear();
    953 
    954    // If the Rule panel is not visible, the selected element's rule models may not have
    955    // been created yet. For example, in 2-pane mode when Fonts is opened as the default
    956    // panel. Select the current node to force the Rule view to create the rule models.
    957    if (!this.ruleViewTool.isPanelVisible()) {
    958      await this.ruleView.selectElement(this.node, false);
    959    }
    960 
    961    // Select the node's inline style as the rule where to write property value changes.
    962    this.selectedRule = this.ruleView.rules.find(
    963      rule => rule.domRule.type === ELEMENT_STYLE
    964    );
    965 
    966    const properties = await this.getFontProperties();
    967    // Assign writer methods to each axis defined in font-variation-settings.
    968    const axes = parseFontVariationAxes(properties["font-variation-settings"]);
    969    Object.keys(axes).map(axis => {
    970      this.writers.set(axis, this.getWriterForAxis(axis));
    971    });
    972 
    973    this.store.dispatch(updateFontEditor(fonts, properties, this.node.actorID));
    974    this.store.dispatch(setEditorDisabled(this.node.isPseudoElement));
    975 
    976    this.inspector.emit("fonteditor-updated");
    977    // Listen to manual changes in the Rule view that could update the Font Editor state
    978    this.ruleView.on("property-value-updated", this.onRulePropertyUpdated);
    979  }
    980 
    981  async update() {
    982    // Stop refreshing if the inspector or store is already destroyed.
    983    if (!this.inspector || !this.store) {
    984      return;
    985    }
    986 
    987    let allFonts = [];
    988 
    989    if (!this.node) {
    990      this.store.dispatch(updateFonts(allFonts));
    991      return;
    992    }
    993 
    994    const { fontOptions } = this.store.getState();
    995    const { previewText } = fontOptions;
    996 
    997    const options = {
    998      includePreviews: true,
    999      // Coerce the type of `supportsFontVariations` to a boolean.
   1000      includeVariations: !!this.pageStyle.supportsFontVariations,
   1001      previewText,
   1002      previewFillStyle: getCssVariableColor(
   1003        "--theme-body-color",
   1004        this.document.ownerGlobal
   1005      ),
   1006    };
   1007 
   1008    // If there are no fonts used on the page, the result is an empty array.
   1009    allFonts = await this.getAllFonts(options);
   1010 
   1011    // Augment each font object with a dataURI for an image with a sample of the font.
   1012    for (const font of [...allFonts]) {
   1013      font.previewUrl = await font.preview.data.string();
   1014    }
   1015 
   1016    // Dispatch to the store if it hasn't been destroyed in the meantime.
   1017    this.store && this.store.dispatch(updateFonts(allFonts));
   1018    // Emit on the inspector if it hasn't been destroyed in the meantime.
   1019    // Pass the current node in the payload so that tests can check the update
   1020    // corresponds to the expected node.
   1021    this.inspector &&
   1022      this.inspector.emitForTests("fontinspector-updated", this.node);
   1023  }
   1024 
   1025  /**
   1026   * Update the "font-variation-settings" CSS property with the state of all touched
   1027   * font variation axes which shouldn't be written to other CSS font properties.
   1028   */
   1029  updateFontVariationSettings() {
   1030    const fontEditor = this.store.getState().fontEditor;
   1031    const name = "font-variation-settings";
   1032    const value = Object.keys(fontEditor.axes)
   1033      // Pick only axes which are supposed to be written to font-variation-settings.
   1034      // Skip registered axes which should be written to a different CSS property.
   1035      .filter(tag => this.writers.get(tag) === this.updateFontVariationSettings)
   1036      // Build a string value for the "font-variation-settings" CSS property
   1037      .map(tag => `"${tag}" ${fontEditor.axes[tag]}`)
   1038      .join(", ");
   1039 
   1040    this.updatePropertyValue(name, value);
   1041  }
   1042 
   1043  /**
   1044   * Preview a property value (live) then sync the changes (debounced) to the Rule view.
   1045   *
   1046   * NOTE: Until Bug 1462591 is addressed, all changes are written to the element's inline
   1047   * style attribute. In this current scenario, Rule.previewPropertyValue()
   1048   * causes the whole inline style representation in the Rule view to update instead of
   1049   * just previewing the change on the element.
   1050   * We keep the debounced call to syncChanges() because it explicitly calls
   1051   * TextProperty.setValue() which performs other actions, including marking the property
   1052   * as "changed" in the Rule view with a green indicator.
   1053   *
   1054   * @param {string} name
   1055   *        CSS property name
   1056   * @param {string}value
   1057   *        CSS property value
   1058   */
   1059  updatePropertyValue(name, value) {
   1060    const textProperty = this.getTextProperty(name);
   1061 
   1062    if (!textProperty) {
   1063      this.selectedRule.createProperty(name, value, "", true);
   1064      return;
   1065    }
   1066 
   1067    if (textProperty.value === value) {
   1068      return;
   1069    }
   1070 
   1071    // Prevent reacting to changes we caused.
   1072    this.ruleView.off("property-value-updated", this.onRulePropertyUpdated);
   1073    // Live preview font property changes on the page.
   1074    textProperty.rule
   1075      .previewPropertyValue(textProperty, value, "")
   1076      .catch(console.error);
   1077 
   1078    // Sync Rule view with changes reflected on the page (debounced).
   1079    this.syncChanges(name, value);
   1080  }
   1081 }
   1082 
   1083 module.exports = FontInspector;