toolkit/modules/LightweightThemeConsumer.jsm
author Emilio Cobos Álvarez <emilio@crisal.io>
Fri, 30 Nov 2018 12:40:25 +0100
changeset 508894 a99600391704f8752e6f05e312fe8e8cd588715a
parent 508765 90189bd84466d74e64d4eb2a33138152e84b241c
child 508895 d23c9c3e1566f64b0257d97597daaae3c3ed0897
permissions -rw-r--r--
Bug 1511138 - Fix LightweightThemeConsumer's use of getComputedStyle. r=jaws,mconley See D13472 for spec quotes and such. Other browsers don't allow getting computed styles in disconnected subtrees and we agreed to follow suit (it does make sense because when you're not on the flat tree it's not defined what you're supposed to inherit from, specially in presence of Shadow DOM). Also, it allows the style system to rely on the DOM being in a sane state. Differential Revision: https://phabricator.services.mozilla.com/D13551

/* 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/. */

var EXPORTED_SYMBOLS = ["LightweightThemeConsumer"];

ChromeUtils.import("resource://gre/modules/Services.jsm");

const DEFAULT_THEME_ID = "default-theme@mozilla.org";
const ICONS = Services.prefs.getStringPref("extensions.webextensions.themes.icons.buttons", "").split(",");

const toolkitVariableMap = [
  ["--lwt-accent-color", {
    lwtProperty: "accentcolor",
    processColor(rgbaChannels, element) {
      if (!rgbaChannels || rgbaChannels.a == 0) {
        return "white";
      }
      // Remove the alpha channel
      const {r, g, b} = rgbaChannels;
      return `rgb(${r}, ${g}, ${b})`;
    },
  }],
  ["--lwt-text-color", {
    lwtProperty: "textcolor",
    processColor(rgbaChannels, element) {
      if (!rgbaChannels) {
        rgbaChannels = {r: 0, g: 0, b: 0};
      }
      // Remove the alpha channel
      const {r, g, b} = rgbaChannels;
      element.setAttribute("lwthemetextcolor", _isTextColorDark(r, g, b) ? "dark" : "bright");
      return `rgba(${r}, ${g}, ${b})`;
    },
  }],
  ["--arrowpanel-background", {
    lwtProperty: "popup",
  }],
  ["--arrowpanel-color", {
    lwtProperty: "popup_text",
    processColor(rgbaChannels, element) {
      const disabledColorVariable = "--panel-disabled-color";

      if (!rgbaChannels) {
        element.removeAttribute("lwt-popup-brighttext");
        element.removeAttribute("lwt-popup-darktext");
        element.style.removeProperty(disabledColorVariable);
        return null;
      }

      let {r, g, b, a} = rgbaChannels;

      if (_isTextColorDark(r, g, b)) {
        element.removeAttribute("lwt-popup-brighttext");
        element.setAttribute("lwt-popup-darktext", "true");
      } else {
        element.removeAttribute("lwt-popup-darktext");
        element.setAttribute("lwt-popup-brighttext", "true");
      }

      element.style.setProperty(disabledColorVariable, `rgba(${r}, ${g}, ${b}, 0.5)`);
      return `rgba(${r}, ${g}, ${b}, ${a})`;
    },
  }],
  ["--arrowpanel-border-color", {
    lwtProperty: "popup_border",
  }],
  ["--lwt-toolbar-field-background-color", {
    lwtProperty: "toolbar_field",
  }],
  ["--lwt-toolbar-field-color", {
    lwtProperty: "toolbar_field_text",
    processColor(rgbaChannels, element) {
      if (!rgbaChannels) {
        element.removeAttribute("lwt-toolbar-field-brighttext");
        return null;
      }
      const {r, g, b, a} = rgbaChannels;
      if (_isTextColorDark(r, g, b)) {
        element.removeAttribute("lwt-toolbar-field-brighttext");
      } else {
        element.setAttribute("lwt-toolbar-field-brighttext", "true");
      }
      return `rgba(${r}, ${g}, ${b}, ${a})`;
    },
  }],
  ["--lwt-toolbar-field-border-color", {
    lwtProperty: "toolbar_field_border",
  }],
  ["--lwt-toolbar-field-focus", {
    lwtProperty: "toolbar_field_focus",
  }],
  ["--lwt-toolbar-field-focus-color", {
    lwtProperty: "toolbar_field_text_focus",
  }],
  ["--toolbar-field-focus-border-color", {
    lwtProperty: "toolbar_field_border_focus",
  }],
];

// Get the theme variables from the app resource directory.
// This allows per-app variables.
ChromeUtils.defineModuleGetter(this, "ThemeContentPropertyList",
  "resource:///modules/ThemeVariableMap.jsm");
ChromeUtils.defineModuleGetter(this, "ThemeVariableMap",
  "resource:///modules/ThemeVariableMap.jsm");
ChromeUtils.defineModuleGetter(this, "LightweightThemeImageOptimizer",
  "resource://gre/modules/addons/LightweightThemeImageOptimizer.jsm");

function LightweightThemeConsumer(aDocument) {
  this._doc = aDocument;
  this._win = aDocument.defaultView;
  this._winId = this._win.windowUtils.outerWindowID;

  Services.obs.addObserver(this, "lightweight-theme-styling-update");

  var temp = {};
  ChromeUtils.import("resource://gre/modules/LightweightThemeManager.jsm", temp);
  this._update(temp.LightweightThemeManager.currentThemeWithPersistedData);

  this._win.addEventListener("resolutionchange", this);
  this._win.addEventListener("unload", this, { once: true });

  let darkThemeMediaQuery = this._win.matchMedia("(-moz-system-dark-theme)");
  darkThemeMediaQuery.addListener(temp.LightweightThemeManager);
  temp.LightweightThemeManager.systemThemeChanged(darkThemeMediaQuery);
}

LightweightThemeConsumer.prototype = {
  _lastData: null,
  // Whether a lightweight theme is enabled.
  _active: false,

  observe(aSubject, aTopic, aData) {
    if (aTopic != "lightweight-theme-styling-update")
      return;

    let parsedData = JSON.parse(aData);
    if (!parsedData) {
      parsedData = { theme: null, experiment: null };
    }

    if (parsedData.window && parsedData.window !== this._winId) {
      return;
    }

    this._update(parsedData.theme, parsedData.experiment);
  },

  handleEvent(aEvent) {
    switch (aEvent.type) {
      case "resolutionchange":
        if (this._active) {
          this._update(this._lastData);
        }
        break;
      case "unload":
        Services.obs.removeObserver(this, "lightweight-theme-styling-update");
        Services.ppmm.sharedData.delete(`theme/${this._winId}`);
        this._win.removeEventListener("resolutionchange", this);
        this._win = this._doc = null;
        break;
    }
  },

  _update(theme, experiment) {
    this._lastData = theme;
    if (theme) {
      theme = LightweightThemeImageOptimizer.optimize(theme, this._win.screen);
    }

    let active = this._active = theme && theme.id !== DEFAULT_THEME_ID;

    if (!theme) {
      theme = {};
    }

    let root = this._doc.documentElement;

    if (active && theme.headerURL) {
      root.setAttribute("lwtheme-image", "true");
    } else {
      root.removeAttribute("lwtheme-image");
    }

    if (active && theme.icons) {
      let activeIcons = Object.keys(theme.icons).join(" ");
      root.setAttribute("lwthemeicons", activeIcons);
    } else {
      root.removeAttribute("lwthemeicons");
    }

    for (let icon of ICONS) {
      let value = theme.icons ? theme.icons[`--${icon}-icon`] : null;
      _setImage(root, active, `--${icon}-icon`, value);
    }

    this._setExperiment(active, experiment, theme.experimental);
    _setImage(root, active, "--lwt-header-image", theme.headerURL);
    _setImage(root, active, "--lwt-additional-images", theme.additionalBackgrounds);
    _setProperties(root, active, theme);

    if (active) {
      root.setAttribute("lwtheme", "true");
    } else {
      root.removeAttribute("lwtheme");
      root.removeAttribute("lwthemetextcolor");
    }

    let contentThemeData = _getContentProperties(this._doc, active, theme);
    Services.ppmm.sharedData.set(`theme/${this._winId}`, contentThemeData);
  },

  _setExperiment(active, experiment, properties) {
    const root = this._doc.documentElement;
    if (this._lastExperimentData) {
      const { stylesheet, usedVariables } = this._lastExperimentData;
      if (stylesheet) {
        stylesheet.remove();
      }
      if (usedVariables) {
        for (const variable of usedVariables) {
          _setProperty(root, false, variable);
        }
      }
    }
    if (active && experiment) {
      this._lastExperimentData = {};
      if (experiment.stylesheet) {
        /* Stylesheet URLs are validated using WebExtension schemas */
        let stylesheetAttr = `href="${experiment.stylesheet}" type="text/css"`;
        let stylesheet = this._doc.createProcessingInstruction("xml-stylesheet",
          stylesheetAttr);
        this._doc.insertBefore(stylesheet, root);
        this._lastExperimentData.stylesheet = stylesheet;
      }
      let usedVariables = [];
      if (properties.colors) {
        for (const property in properties.colors) {
          const cssVariable = experiment.colors[property];
          const value = _sanitizeCSSColor(root.ownerDocument, properties.colors[property]);
          _setProperty(root, active, cssVariable, value);
          usedVariables.push(cssVariable);
        }
      }
      if (properties.images) {
        for (const property in properties.images) {
          const cssVariable = experiment.images[property];
          _setProperty(root, active, cssVariable, `url(${properties.images[property]})`);
          usedVariables.push(cssVariable);
        }
      }
      if (properties.properties) {
        for (const property in properties.properties) {
          const cssVariable = experiment.properties[property];
          _setProperty(root, active, cssVariable, properties.properties[property]);
          usedVariables.push(cssVariable);
        }
      }
      this._lastExperimentData.usedVariables = usedVariables;
    } else {
      this._lastExperimentData = null;
    }
  },
};

function _getContentProperties(doc, active, data) {
  if (!active) {
    return {};
  }
  let properties = {};
  for (let property in data) {
    if (ThemeContentPropertyList.includes(property)) {
      properties[property] = _parseRGBA(_sanitizeCSSColor(doc, data[property]));
    }
  }
  return properties;
}

function _setImage(aRoot, aActive, aVariableName, aURLs) {
  if (aURLs && !Array.isArray(aURLs)) {
    aURLs = [aURLs];
  }
  _setProperty(aRoot, aActive, aVariableName, aURLs && aURLs.map(v => `url("${v.replace(/"/g, '\\"')}")`).join(","));
}

function _setProperty(elem, active, variableName, value) {
  if (active && value) {
    elem.style.setProperty(variableName, value);
  } else {
    elem.style.removeProperty(variableName);
  }
}

function _setProperties(root, active, themeData) {
  for (let map of [toolkitVariableMap, ThemeVariableMap]) {
    for (let [cssVarName, definition] of map) {
      const {
        lwtProperty,
        optionalElementID,
        processColor,
        isColor = true,
      } = definition;
      let elem = optionalElementID ? root.ownerDocument.getElementById(optionalElementID)
                                   : root;

      let val = themeData[lwtProperty];
      if (isColor) {
        val = _sanitizeCSSColor(root.ownerDocument, val);
        if (processColor) {
          val = processColor(_parseRGBA(val), elem);
        }
      }
      _setProperty(elem, active, cssVarName, val);
    }
  }
}

function _sanitizeCSSColor(doc, cssColor) {
  if (!cssColor) {
    return null;
  }
  const HTML_NS = "http://www.w3.org/1999/xhtml";
  // style.color normalizes color values and makes invalid ones black, so a
  // simple round trip gets us a sanitized color value.
  let div = doc.createElementNS(HTML_NS, "div");
  div.style.color = "black";
  div.style.display = "none";
  let span = doc.createElementNS(HTML_NS, "span");
  span.style.color = cssColor;

  // CSS variables are not allowed and should compute to black.
  if (span.style.color.indexOf("var(") !== -1) {
    span.style.color = "";
  }

  div.appendChild(span);
  doc.documentElement.appendChild(div);
  cssColor = doc.defaultView.getComputedStyle(span).color;
  div.remove();
  return cssColor;
}

function _parseRGBA(aColorString) {
  if (!aColorString) {
    return null;
  }
  var rgba = aColorString.replace(/(rgba?\()|(\)$)/g, "").split(",");
  rgba = rgba.map(x => parseFloat(x));
  return {
    r: rgba[0],
    g: rgba[1],
    b: rgba[2],
    a: 3 in rgba ? rgba[3] : 1,
  };
}

function _isTextColorDark(r, g, b) {
  return (0.2125 * r + 0.7154 * g + 0.0721 * b) <= 110;
}