Source: index.js

import Ruler from "./ruler.js";
import { onReady } from "./dom.js";

/** Class for FontFaceObserver. */
class FontFaceObserver {
  static Ruler = Ruler;

  /**
   * @type {null|boolean}
   */
  static HAS_WEBKIT_FALLBACK_BUG = null;

  /**
   * @type {null|boolean}
   */
  static HAS_SAFARI_10_BUG = null;

  /**
   * @type {null|boolean}
   */
  static SUPPORTS_STRETCH = null;

  /**
   * @type {null|boolean}
   */
  static SUPPORTS_NATIVE_FONT_LOADING = null;

  /**
   * @type {number}
   */
  static DEFAULT_TIMEOUT = 3000;

  /**
   * @return {string}
   */
  static getUserAgent() {
    return window.navigator.userAgent;
  }

  /**
   * @return {string}
   */
  static getNavigatorVendor() {
    return window.navigator.vendor;
  }

  /**
   * Returns true if this browser is WebKit and it has the fallback bug which is
   * present in WebKit 536.11 and earlier.
   *
   * @return {boolean}
   */
  static hasWebKitFallbackBug() {
    if (FontFaceObserver.HAS_WEBKIT_FALLBACK_BUG === null) {
      const match = /AppleWebKit\/([0-9]+)(?:\.([0-9]+))/.exec(
        FontFaceObserver.getUserAgent()
      );

      FontFaceObserver.HAS_WEBKIT_FALLBACK_BUG =
        !!match &&
        (parseInt(match[1], 10) < 536 ||
          (parseInt(match[1], 10) === 536 && parseInt(match[2], 10) <= 11));
    }
    return FontFaceObserver.HAS_WEBKIT_FALLBACK_BUG;
  }

  /**
   * Returns true if the browser has the Safari 10 bugs. The native font load
   * API in Safari 10 has two bugs that cause the document.fonts.load and
   * FontFace.prototype.load methods to return promises that don't reliably get
   * settled.
   *
   * The bugs are described in more detail here:
   *  - https://bugs.webkit.org/show_bug.cgi?id=165037
   *  - https://bugs.webkit.org/show_bug.cgi?id=164902
   *
   * If the browser is made by Apple, and has native font loading support, it is
   * potentially affected. But the API was fixed around AppleWebKit version 603,
   * so any newer versions that that does not contain the bug.
   *
   * @return {boolean}
   */
  static hasSafari10Bug() {
    if (FontFaceObserver.HAS_SAFARI_10_BUG === null) {
      if (
        FontFaceObserver.supportsNativeFontLoading() &&
        /Apple/.test(FontFaceObserver.getNavigatorVendor())
      ) {
        const match = /AppleWebKit\/([0-9]+)(?:\.([0-9]+))(?:\.([0-9]+))/.exec(
          FontFaceObserver.getUserAgent()
        );

        FontFaceObserver.HAS_SAFARI_10_BUG =
          !!match && parseInt(match[1], 10) < 603;
      } else {
        FontFaceObserver.HAS_SAFARI_10_BUG = false;
      }
    }
    return FontFaceObserver.HAS_SAFARI_10_BUG;
  }

  /**
   * Returns true if the browser supports the native font loading API.
   *
   * @return {boolean}
   */
  static supportsNativeFontLoading() {
    if (FontFaceObserver.SUPPORTS_NATIVE_FONT_LOADING === null) {
      FontFaceObserver.SUPPORTS_NATIVE_FONT_LOADING = !!document["fonts"];
    }
    return FontFaceObserver.SUPPORTS_NATIVE_FONT_LOADING;
  }

  /**
   * Returns true if the browser supports font-style in the font short-hand
   * syntax.
   *
   * @return {boolean}
   */
  static supportStretch() {
    if (FontFaceObserver.SUPPORTS_STRETCH === null) {
      const div = document.createElement("div");

      try {
        div.style.font = "condensed 100px sans-serif";
      } catch (e) {}
      FontFaceObserver.SUPPORTS_STRETCH = div.style.font !== "";
    }

    return FontFaceObserver.SUPPORTS_STRETCH;
  }

  /**
   * @typedef {Object} Descriptors
   * @property {string|undefined} style
   * @property {string|undefined} weight
   * @property {string|undefined} stretch
   */
  /**
   *
   * @param {string} family font-family name (required)
   * @param {Descriptors} descriptors an object describing the variation
   * (optional). The object can contain `weight`, `style`, and `stretch`
   * properties. If a property is not present it will default to `normal`.
   */
  constructor(family, descriptors = {}) {
    this.family = family;

    this.style = descriptors.style || "normal";
    this.weight = descriptors.weight || "normal";
    this.stretch = descriptors.stretch || "normal";

    return this;
  }

  /**
   * @param {string=} text Optional test string to use for detecting if a font
   * is available.
   * @param {number=} timeout Optional timeout for giving up on font load
   * detection and rejecting the promise (defaults to 3 seconds).
   * @return {Promise.<FontFaceObserver>}
   */
  load(text, timeout) {
    const that = this;
    const testString = text || "BESbswy";
    let timeoutId = 0;
    const timeoutValue = timeout || FontFaceObserver.DEFAULT_TIMEOUT;
    const start = that.getTime();

    return new Promise(function(resolve, reject) {
      if (
        FontFaceObserver.supportsNativeFontLoading() &&
        !FontFaceObserver.hasSafari10Bug()
      ) {
        const loader = new Promise(function(resolve, reject) {
          const check = function() {
            const now = that.getTime();

            if (now - start >= timeoutValue) {
              reject(new Error("" + timeoutValue + "ms timeout exceeded"));
            } else {
              document.fonts
                .load(that.getStyle('"' + that["family"] + '"'), testString)
                .then(function(fonts) {
                  if (fonts.length >= 1) {
                    resolve();
                  } else {
                    setTimeout(check, 25);
                  }
                }, reject);
            }
          };
          check();
        });

        const timer = new Promise(function(resolve, reject) {
          timeoutId = setTimeout(function() {
            reject(new Error("" + timeoutValue + "ms timeout exceeded"));
          }, timeoutValue);
        });

        Promise.race([timer, loader]).then(function() {
          clearTimeout(timeoutId);
          resolve(that);
        }, reject);
      } else {
        onReady(function() {
          const rulerA = new Ruler(testString);
          const rulerB = new Ruler(testString);
          const rulerC = new Ruler(testString);

          let widthA = -1;
          let widthB = -1;
          let widthC = -1;

          let fallbackWidthA = -1;
          let fallbackWidthB = -1;
          let fallbackWidthC = -1;

          const container = document.createElement("div");

          /**
           * @private
           */
          function removeContainer() {
            if (container.parentNode !== null) {
              container.parentNode.removeChild(container);
            }
          }

          /**
           * @private
           *
           * If metric compatible fonts are detected, one of the widths will be
           * -1. This is because a metric compatible font won't trigger a scroll
           * event. We work around this by considering a font loaded if at least
           * two of the widths are the same. Because we have three widths, this
           * still prevents false positives.
           *
           * Cases:
           * 1) Font loads: both a, b and c are called and have the same value.
           * 2) Font fails to load: resize callback is never called and timeout
           *    happens.
           * 3) WebKit bug: both a, b and c are called and have the same value,
           *    but the values are equal to one of the last resort fonts, we
           *    ignore this and continue waiting until we get new values (or a
           *    timeout).
           */
          function check() {
            if (
              (widthA != -1 && widthB != -1) ||
              (widthA != -1 && widthC != -1) ||
              (widthB != -1 && widthC != -1)
            ) {
              if (widthA == widthB || widthA == widthC || widthB == widthC) {
                // All values are the same, so the browser has most likely
                // loaded the web font

                if (FontFaceObserver.hasWebKitFallbackBug()) {
                  // Except if the browser has the WebKit fallback bug, in which
                  // case we check to see if all values are set to one of the
                  // last resort fonts.

                  if (
                    (widthA == fallbackWidthA &&
                      widthB == fallbackWidthA &&
                      widthC == fallbackWidthA) ||
                    (widthA == fallbackWidthB &&
                      widthB == fallbackWidthB &&
                      widthC == fallbackWidthB) ||
                    (widthA == fallbackWidthC &&
                      widthB == fallbackWidthC &&
                      widthC == fallbackWidthC)
                  ) {
                    // The width we got matches some of the known last resort
                    // fonts, so let's assume we're dealing with the last resort
                    // font.
                    return;
                  }
                }
                removeContainer();
                clearTimeout(timeoutId);
                resolve(that);
              }
            }
          }

          // This ensures the scroll direction is correct.
          container.dir = "ltr";

          rulerA.setFont(that.getStyle("sans-serif"));
          rulerB.setFont(that.getStyle("serif"));
          rulerC.setFont(that.getStyle("monospace"));

          container.appendChild(rulerA.getElement());
          container.appendChild(rulerB.getElement());
          container.appendChild(rulerC.getElement());

          document.body.appendChild(container);

          fallbackWidthA = rulerA.getWidth();
          fallbackWidthB = rulerB.getWidth();
          fallbackWidthC = rulerC.getWidth();

          function checkForTimeout() {
            const now = that.getTime();

            if (now - start >= timeoutValue) {
              removeContainer();
              reject(new Error("" + timeoutValue + "ms timeout exceeded"));
            } else {
              const hidden = document["hidden"];
              if (hidden === true || hidden === undefined) {
                widthA = rulerA.getWidth();
                widthB = rulerB.getWidth();
                widthC = rulerC.getWidth();
                check();
              }
              timeoutId = setTimeout(checkForTimeout, 50);
            }
          }

          checkForTimeout();

          rulerA.onResize(function(width) {
            widthA = width;
            check();
          });

          rulerA.setFont(that.getStyle('"' + that["family"] + '",sans-serif'));

          rulerB.onResize(function(width) {
            widthB = width;
            check();
          });

          rulerB.setFont(that.getStyle('"' + that["family"] + '",serif'));

          rulerC.onResize(function(width) {
            widthC = width;
            check();
          });

          rulerC.setFont(that.getStyle('"' + that["family"] + '",monospace'));
        });
      }
    });
  }

  /**
   * @private
   *
   * @param {string} family
   * @return {string}
   */
  getStyle(family) {
    return [
      this.style,
      this.weight,
      FontFaceObserver.supportStretch() ? this.stretch : "",
      "100px",
      family
    ].join(" ");
  }
  /**
   * @private
   *
   * @return {number}
   */
  getTime() {
    return new Date().getTime();
  }
}

export default FontFaceObserver;