summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/fonts/fonts.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/inspector/fonts/fonts.js')
-rw-r--r--devtools/client/inspector/fonts/fonts.js1112
1 files changed, 1112 insertions, 0 deletions
diff --git a/devtools/client/inspector/fonts/fonts.js b/devtools/client/inspector/fonts/fonts.js
new file mode 100644
index 0000000000..e17d306bdc
--- /dev/null
+++ b/devtools/client/inspector/fonts/fonts.js
@@ -0,0 +1,1112 @@
+/* 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 {
+ gDevTools,
+} = require("resource://devtools/client/framework/devtools.js");
+const { getColor } = require("resource://devtools/client/shared/theme.js");
+const {
+ createFactory,
+ createElement,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const {
+ Provider,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+const { debounce } = require("resource://devtools/shared/debounce.js");
+const {
+ style: { ELEMENT_STYLE },
+} = require("resource://devtools/shared/constants.js");
+
+const FontsApp = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontsApp.js")
+);
+
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const INSPECTOR_L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+
+const {
+ parseFontVariationAxes,
+} = require("resource://devtools/client/inspector/fonts/utils/font-utils.js");
+
+const fontDataReducer = require("resource://devtools/client/inspector/fonts/reducers/fonts.js");
+const fontEditorReducer = require("resource://devtools/client/inspector/fonts/reducers/font-editor.js");
+const fontOptionsReducer = require("resource://devtools/client/inspector/fonts/reducers/font-options.js");
+const {
+ updateFonts,
+} = require("resource://devtools/client/inspector/fonts/actions/fonts.js");
+const {
+ applyInstance,
+ resetFontEditor,
+ setEditorDisabled,
+ updateAxis,
+ updateFontEditor,
+ updateFontProperty,
+} = require("resource://devtools/client/inspector/fonts/actions/font-editor.js");
+const {
+ updatePreviewText,
+} = require("resource://devtools/client/inspector/fonts/actions/font-options.js");
+
+const FONT_PROPERTIES = [
+ "font-family",
+ "font-optical-sizing",
+ "font-size",
+ "font-stretch",
+ "font-style",
+ "font-variation-settings",
+ "font-weight",
+ "letter-spacing",
+ "line-height",
+];
+const REGISTERED_AXES_TO_FONT_PROPERTIES = {
+ ital: "font-style",
+ opsz: "font-optical-sizing",
+ slnt: "font-style",
+ wdth: "font-stretch",
+ wght: "font-weight",
+};
+const REGISTERED_AXES = Object.keys(REGISTERED_AXES_TO_FONT_PROPERTIES);
+
+const HISTOGRAM_FONT_TYPE_DISPLAYED = "DEVTOOLS_FONTEDITOR_FONT_TYPE_DISPLAYED";
+
+class FontInspector {
+ constructor(inspector, window) {
+ this.cssProperties = inspector.cssProperties;
+ this.document = window.document;
+ this.inspector = inspector;
+ // Selected node in the markup view. For text nodes, this points to their parent node
+ // element. Font faces and font properties for this node will be shown in the editor.
+ this.node = null;
+ this.nodeComputedStyle = {};
+ // The page style actor that will be providing the style information.
+ this.pageStyle = null;
+ this.ruleViewTool = this.inspector.getPanel("ruleview");
+ this.ruleView = this.ruleViewTool.view;
+ this.selectedRule = null;
+ this.store = this.inspector.store;
+ // Map CSS property names and variable font axis names to methods that write their
+ // corresponding values to the appropriate TextProperty from the Rule view.
+ // Values of variable font registered axes may be written to CSS font properties under
+ // certain cascade circumstances and platform support. @see `getWriterForAxis(axis)`
+ this.writers = new Map();
+
+ this.store.injectReducer("fontOptions", fontOptionsReducer);
+ this.store.injectReducer("fontData", fontDataReducer);
+ this.store.injectReducer("fontEditor", fontEditorReducer);
+
+ this.syncChanges = debounce(this.syncChanges, 100, this);
+ this.onInstanceChange = this.onInstanceChange.bind(this);
+ this.onNewNode = this.onNewNode.bind(this);
+ this.onPreviewTextChange = debounce(this.onPreviewTextChange, 100, this);
+ this.onPropertyChange = this.onPropertyChange.bind(this);
+ this.onRulePropertyUpdated = debounce(
+ this.onRulePropertyUpdated,
+ 300,
+ this
+ );
+ this.onToggleFontHighlight = this.onToggleFontHighlight.bind(this);
+ this.onThemeChanged = this.onThemeChanged.bind(this);
+ this.update = this.update.bind(this);
+ this.updateFontVariationSettings =
+ this.updateFontVariationSettings.bind(this);
+ this.onResourceAvailable = this.onResourceAvailable.bind(this);
+
+ this.init();
+ }
+
+ /**
+ * Map CSS font property names to a list of values that should be skipped when consuming
+ * font properties from CSS rules. The skipped values are mostly keyword values like
+ * `bold`, `initial`, `unset`. Computed values will be used instead of such keywords.
+ *
+ * @return {Map}
+ */
+ get skipValuesMap() {
+ if (!this._skipValuesMap) {
+ this._skipValuesMap = new Map();
+
+ for (const property of FONT_PROPERTIES) {
+ const values = this.cssProperties.getValues(property);
+
+ switch (property) {
+ case "line-height":
+ case "letter-spacing":
+ // There's special handling for "normal" so remove it from the skip list.
+ this.skipValuesMap.set(
+ property,
+ values.filter(value => value !== "normal")
+ );
+ break;
+ default:
+ this.skipValuesMap.set(property, values);
+ }
+ }
+ }
+
+ return this._skipValuesMap;
+ }
+
+ init() {
+ if (!this.inspector) {
+ return;
+ }
+
+ const fontsApp = FontsApp({
+ onInstanceChange: this.onInstanceChange,
+ onToggleFontHighlight: this.onToggleFontHighlight,
+ onPreviewTextChange: this.onPreviewTextChange,
+ onPropertyChange: this.onPropertyChange,
+ });
+
+ const provider = createElement(
+ Provider,
+ {
+ id: "fontinspector",
+ key: "fontinspector",
+ store: this.store,
+ title: INSPECTOR_L10N.getStr("inspector.sidebar.fontInspectorTitle"),
+ },
+ fontsApp
+ );
+
+ // Expose the provider to let inspector.js use it in setupSidebar.
+ this.provider = provider;
+
+ this.inspector.selection.on("new-node-front", this.onNewNode);
+ // @see ToolSidebar.onSidebarTabSelected()
+ this.inspector.sidebar.on("fontinspector-selected", this.onNewNode);
+
+ this.inspector.toolbox.resourceCommand.watchResources(
+ [this.inspector.toolbox.resourceCommand.TYPES.DOCUMENT_EVENT],
+ { onAvailable: this.onResourceAvailable }
+ );
+
+ // Listen for theme changes as the color of the previews depend on the theme
+ gDevTools.on("theme-switched", this.onThemeChanged);
+ }
+
+ /**
+ * Convert a value for font-size between two CSS unit types.
+ * Conversion is done via pixels. If neither of the two given unit types is "px",
+ * recursively get the value in pixels, then convert that result to the desired unit.
+ *
+ * @param {String} property
+ * Property name for the converted value.
+ * Assumed to be "font-size", but special case for "line-height".
+ * @param {Number} value
+ * Numeric value to convert.
+ * @param {String} fromUnit
+ * CSS unit to convert from.
+ * @param {String} toUnit
+ * CSS unit to convert to.
+ * @return {Number}
+ * Converted numeric value.
+ */
+ async convertUnits(property, value, fromUnit, toUnit) {
+ if (value !== parseFloat(value)) {
+ throw TypeError(
+ `Invalid value for conversion. Expected Number, got ${value}`
+ );
+ }
+
+ const shouldReturn = () => {
+ // Early return if:
+ // - conversion is not required
+ // - property is `line-height`
+ // - `fromUnit` is `em` and `toUnit` is unitless
+ const conversionNotRequired = fromUnit === toUnit || value === 0;
+ const forLineHeight =
+ property === "line-height" && fromUnit === "" && toUnit === "em";
+ const isEmToUnitlessConversion = fromUnit === "em" && toUnit === "";
+ return conversionNotRequired || forLineHeight || isEmToUnitlessConversion;
+ };
+
+ if (shouldReturn()) {
+ return value;
+ }
+
+ // If neither unit is in pixels, first convert the value to pixels.
+ // Reassign input value and source CSS unit.
+ if (toUnit !== "px" && fromUnit !== "px") {
+ value = await this.convertUnits(property, value, fromUnit, "px");
+ fromUnit = "px";
+ }
+
+ // Whether the conversion is done from pixels.
+ const fromPx = fromUnit === "px";
+ // Determine the target CSS unit for conversion.
+ const unit = toUnit === "px" ? fromUnit : toUnit;
+ // Default output value to input value for a 1-to-1 conversion as a guard against
+ // unrecognized CSS units. It will not be correct, but it will also not break.
+ let out = value;
+
+ const converters = {
+ in: () => (fromPx ? value / 96 : value * 96),
+ cm: () => (fromPx ? value * 0.02645833333 : value / 0.02645833333),
+ mm: () => (fromPx ? value * 0.26458333333 : value / 0.26458333333),
+ pt: () => (fromPx ? value * 0.75 : value / 0.75),
+ pc: () => (fromPx ? value * 0.0625 : value / 0.0625),
+ "%": async () => {
+ const fontSize = await this.getReferenceFontSize(property, unit);
+ return fromPx
+ ? (value * 100) / parseFloat(fontSize)
+ : (value / 100) * parseFloat(fontSize);
+ },
+ rem: async () => {
+ const fontSize = await this.getReferenceFontSize(property, unit);
+ return fromPx
+ ? value / parseFloat(fontSize)
+ : value * parseFloat(fontSize);
+ },
+ vh: async () => {
+ const { height } = await this.getReferenceBox(property, unit);
+ return fromPx ? (value * 100) / height : (value / 100) * height;
+ },
+ vw: async () => {
+ const { width } = await this.getReferenceBox(property, unit);
+ return fromPx ? (value * 100) / width : (value / 100) * width;
+ },
+ vmin: async () => {
+ const { width, height } = await this.getReferenceBox(property, unit);
+ return fromPx
+ ? (value * 100) / Math.min(width, height)
+ : (value / 100) * Math.min(width, height);
+ },
+ vmax: async () => {
+ const { width, height } = await this.getReferenceBox(property, unit);
+ return fromPx
+ ? (value * 100) / Math.max(width, height)
+ : (value / 100) * Math.max(width, height);
+ },
+ };
+
+ if (converters.hasOwnProperty(unit)) {
+ const converter = converters[unit];
+ out = await converter();
+ }
+
+ // Special handling for unitless line-height.
+ if (unit === "em" || (unit === "" && property === "line-height")) {
+ const fontSize = await this.getReferenceFontSize(property, unit);
+ out = fromPx
+ ? value / parseFloat(fontSize)
+ : value * parseFloat(fontSize);
+ }
+
+ // Catch any NaN or Infinity as result of dividing by zero in any
+ // of the relative unit conversions which rely on external values.
+ if (isNaN(out) || Math.abs(out) === Infinity) {
+ out = 0;
+ }
+
+ // Return values limited to 3 decimals when:
+ // - the unit is converted from pixels to something else
+ // - the value is for letter spacing, regardless of unit (allow sub-pixel precision)
+ if (fromPx || property === "letter-spacing") {
+ // Round values like 1.000 to 1
+ return out === Math.round(out) ? Math.round(out) : out.toFixed(3);
+ }
+
+ // Round pixel values.
+ return Math.round(out);
+ }
+
+ /**
+ * Destruction function called when the inspector is destroyed. Removes event listeners
+ * and cleans up references.
+ */
+ destroy() {
+ this.inspector.selection.off("new-node-front", this.onNewNode);
+ this.inspector.sidebar.off("fontinspector-selected", this.onNewNode);
+ this.ruleView.off("property-value-updated", this.onRulePropertyUpdated);
+ gDevTools.off("theme-switched", this.onThemeChanged);
+
+ this.inspector.toolbox.resourceCommand.unwatchResources(
+ [this.inspector.toolbox.resourceCommand.TYPES.DOCUMENT_EVENT],
+ { onAvailable: this.onResourceAvailable }
+ );
+
+ this.fontsHighlighter = null;
+ this.document = null;
+ this.inspector = null;
+ this.node = null;
+ this.nodeComputedStyle = {};
+ this.pageStyle = null;
+ this.ruleView = null;
+ this.selectedRule = null;
+ this.store = null;
+ this.writers.clear();
+ this.writers = null;
+ }
+
+ onResourceAvailable(resources) {
+ for (const resource of resources) {
+ if (
+ resource.resourceType ===
+ this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT &&
+ resource.name === "will-navigate" &&
+ resource.targetFront.isTopLevel
+ ) {
+ // Reset the fontsHighlighter so the next call to `onToggleFontHighlight` will
+ // re-create it from the inspector front tied to the new document.
+ this.fontsHighlighter = null;
+ }
+ }
+ }
+
+ /**
+ * Get all expected CSS font properties and values from the node's matching rules and
+ * fallback to computed style. Skip CSS Custom Properties, `calc()` and keyword values.
+ *
+ * @return {Object}
+ */
+ async getFontProperties() {
+ const properties = {};
+
+ // First, get all expected font properties from computed styles, if available.
+ for (const prop of FONT_PROPERTIES) {
+ properties[prop] =
+ this.nodeComputedStyle[prop] && this.nodeComputedStyle[prop].value
+ ? this.nodeComputedStyle[prop].value
+ : "";
+ }
+
+ // Then, replace with enabled font properties found on any of the rules that apply.
+ for (const rule of this.ruleView.rules) {
+ if (rule.inherited) {
+ continue;
+ }
+
+ for (const textProp of rule.textProps) {
+ if (
+ FONT_PROPERTIES.includes(textProp.name) &&
+ !this.skipValuesMap.get(textProp.name).includes(textProp.value) &&
+ !textProp.value.includes("calc(") &&
+ !textProp.value.includes("var(") &&
+ !textProp.overridden &&
+ textProp.enabled
+ ) {
+ properties[textProp.name] = textProp.value;
+ }
+ }
+ }
+
+ return properties;
+ }
+
+ async getFontsForNode(node, options) {
+ // In case we've been destroyed in the meantime
+ if (!this.document) {
+ return [];
+ }
+
+ const fonts = await this.pageStyle
+ .getUsedFontFaces(node, options)
+ .catch(console.error);
+ if (!fonts) {
+ return [];
+ }
+
+ return fonts;
+ }
+
+ async getAllFonts(options) {
+ // In case we've been destroyed in the meantime
+ if (!this.document) {
+ return [];
+ }
+
+ const inspectorFronts = await this.inspector.getAllInspectorFronts();
+
+ let allFonts = [];
+ for (const { pageStyle } of inspectorFronts) {
+ allFonts = allFonts.concat(await pageStyle.getAllUsedFontFaces(options));
+ }
+
+ return allFonts;
+ }
+
+ /**
+ * Get the box dimensions used for unit conversion according to the CSS property and
+ * target CSS unit.
+ *
+ * @param {String} property
+ * CSS property
+ * @param {String} unit
+ * Target CSS unit
+ * @return {Promise}
+ * Promise that resolves with an object with box dimensions in pixels.
+ */
+ async getReferenceBox(property, unit) {
+ const box = { width: 0, height: 0 };
+ const node = await this.getReferenceNode(property, unit).catch(
+ console.error
+ );
+
+ if (!node) {
+ return box;
+ }
+
+ switch (unit) {
+ case "vh":
+ case "vw":
+ case "vmin":
+ case "vmax":
+ const dim = await node.getOwnerGlobalDimensions().catch(console.error);
+ if (dim) {
+ box.width = dim.innerWidth;
+ box.height = dim.innerHeight;
+ }
+ break;
+
+ case "%":
+ const style = await this.pageStyle
+ .getComputed(node)
+ .catch(console.error);
+ if (style) {
+ box.width = style.width.value;
+ box.height = style.height.value;
+ }
+ break;
+ }
+
+ return box;
+ }
+
+ /**
+ * Get the refernece font size value used for unit conversion according to the
+ * CSS property and target CSS unit.
+ *
+ * @param {String} property
+ * CSS property
+ * @param {String} unit
+ * Target CSS unit
+ * @return {Promise}
+ * Promise that resolves with the reference font size value or null if there
+ * was an error getting that value.
+ */
+ async getReferenceFontSize(property, unit) {
+ const node = await this.getReferenceNode(property, unit).catch(
+ console.error
+ );
+ if (!node) {
+ return null;
+ }
+
+ const style = await this.pageStyle.getComputed(node).catch(console.error);
+ if (!style) {
+ return null;
+ }
+
+ return style["font-size"].value;
+ }
+
+ /**
+ * Get the reference node used in measurements for unit conversion according to the
+ * the CSS property and target CSS unit type.
+ *
+ * @param {String} property
+ * CSS property
+ * @param {String} unit
+ * Target CSS unit
+ * @return {Promise}
+ * Promise that resolves with the reference node used in measurements for unit
+ * conversion.
+ */
+ async getReferenceNode(property, unit) {
+ let node;
+
+ switch (property) {
+ case "line-height":
+ case "letter-spacing":
+ node = this.node;
+ break;
+ default:
+ node = this.node.parentNode();
+ }
+
+ switch (unit) {
+ case "rem":
+ // Regardless of CSS property, always use the root document element for "rem".
+ node = await this.node.walkerFront.documentElement();
+ break;
+ }
+
+ return node;
+ }
+
+ /**
+ * Get a reference to a TextProperty instance from the current selected rule for a
+ * given property name.
+ *
+ * @param {String} name
+ * CSS property name
+ * @return {TextProperty|null}
+ */
+ getTextProperty(name) {
+ if (!this.selectedRule) {
+ return null;
+ }
+
+ return this.selectedRule.textProps.find(
+ prop => prop.name === name && prop.enabled && !prop.overridden
+ );
+ }
+
+ /**
+ * Given the axis name of a registered axis, return a method which updates the
+ * corresponding CSS font property when called with a value.
+ *
+ * All variable font axes can be written in the value of the "font-variation-settings"
+ * CSS font property. In CSS Fonts Level 4, registered axes values can be used as
+ * values of font properties, like "font-weight", "font-stretch" and "font-style".
+ *
+ * Axes declared in "font-variation-settings", either on the rule or inherited,
+ * overwrite any corresponding font properties. Updates to these axes must be written
+ * to "font-variation-settings" to preserve the cascade. Authors are discouraged from
+ * using this practice. Whenever possible, registered axes values should be written to
+ * their corresponding font properties.
+ *
+ * Registered axis name to font property mapping:
+ * - wdth -> font-stretch
+ * - wght -> font-weight
+ * - opsz -> font-optical-sizing
+ * - slnt -> font-style
+ * - ital -> font-style
+ *
+ * @param {String} axis
+ * Name of registered axis.
+ * @return {Function}
+ * Method to call which updates the corresponding CSS font property.
+ */
+ getWriterForAxis(axis) {
+ // Find any declaration of "font-variation-setttings".
+ const FVSComputedStyle = this.nodeComputedStyle["font-variation-settings"];
+
+ // If "font-variation-settings" CSS property is defined (on the rule or inherited)
+ // and contains a declaration for the given registered axis, write to it.
+ if (FVSComputedStyle && FVSComputedStyle.value.includes(axis)) {
+ return this.updateFontVariationSettings;
+ }
+
+ // Get corresponding CSS font property value for registered axis.
+ const property = REGISTERED_AXES_TO_FONT_PROPERTIES[axis];
+
+ return value => {
+ let condition = false;
+
+ switch (axis) {
+ case "wght":
+ // Whether the page supports values of font-weight from CSS Fonts Level 4.
+ condition = this.pageStyle.supportsFontWeightLevel4;
+ break;
+
+ case "wdth":
+ // font-stretch in CSS Fonts Level 4 accepts percentage units.
+ value = `${value}%`;
+ // Whether the page supports values of font-stretch from CSS Fonts Level 4.
+ condition = this.pageStyle.supportsFontStretchLevel4;
+ break;
+
+ case "slnt":
+ // font-style in CSS Fonts Level 4 accepts an angle value.
+ // We have to invert the sign of the angle because CSS and OpenType measure
+ // in opposite directions.
+ value = -value;
+ value = `oblique ${value}deg`;
+ // Whether the page supports values of font-style from CSS Fonts Level 4.
+ condition = this.pageStyle.supportsFontStyleLevel4;
+ break;
+ }
+
+ if (condition) {
+ this.updatePropertyValue(property, value);
+ } else {
+ // Replace the writer method for this axis so it won't get called next time.
+ this.writers.set(axis, this.updateFontVariationSettings);
+ // Fall back to writing to font-variation-settings together with all other axes.
+ this.updateFontVariationSettings();
+ }
+ };
+ }
+
+ /**
+ * Given a CSS property name or axis name of a variable font, return a method which
+ * updates the corresponding CSS font property when called with a value.
+ *
+ * This is used to distinguish between CSS font properties, registered axes and
+ * custom axes. Registered axes, like "wght" and "wdth", should be written to
+ * corresponding CSS properties, like "font-weight" and "font-stretch".
+ *
+ * Unrecognized names (which aren't font property names or registered axes names) are
+ * considered to be custom axes names and will be written to the
+ * "font-variation-settings" CSS property.
+ *
+ * @param {String} name
+ * CSS property name or axis name.
+ * @return {Function}
+ * Method which updates the rule view and page style.
+ */
+ getWriterForProperty(name) {
+ if (this.writers.has(name)) {
+ return this.writers.get(name);
+ }
+
+ if (REGISTERED_AXES.includes(name)) {
+ this.writers.set(name, this.getWriterForAxis(name));
+ } else if (FONT_PROPERTIES.includes(name)) {
+ this.writers.set(name, value => {
+ this.updatePropertyValue(name, value);
+ });
+ } else {
+ this.writers.set(name, this.updateFontVariationSettings);
+ }
+
+ return this.writers.get(name);
+ }
+
+ /**
+ * Check if the font inspector panel is visible.
+ *
+ * @return {Boolean}
+ */
+ isPanelVisible() {
+ return (
+ this.inspector &&
+ this.inspector.sidebar &&
+ this.inspector.sidebar.getCurrentTabID() === "fontinspector"
+ );
+ }
+
+ /**
+ * Upon a new node selection, log some interesting telemetry probes.
+ */
+ logTelemetryProbesOnNewNode() {
+ const { fontEditor } = this.store.getState();
+ const { telemetry } = this.inspector;
+
+ // Log data about the currently edited font (if any).
+ // Note that the edited font is always the first one from the fontEditor.fonts array.
+ const editedFont = fontEditor.fonts[0];
+ if (!editedFont) {
+ return;
+ }
+
+ const nbOfAxes = editedFont.variationAxes
+ ? editedFont.variationAxes.length
+ : 0;
+ telemetry
+ .getHistogramById(HISTOGRAM_FONT_TYPE_DISPLAYED)
+ .add(!nbOfAxes ? "nonvariable" : "variable");
+ }
+
+ /**
+ * Sync the Rule view with the latest styles from the page. Called in a debounced way
+ * (see constructor) after property changes are applied directly to the CSS style rule
+ * on the page circumventing direct TextProperty.setValue() which triggers expensive DOM
+ * operations in TextPropertyEditor.update().
+ *
+ * @param {String} name
+ * CSS property name
+ * @param {String} value
+ * CSS property value
+ */
+ async syncChanges(name, value) {
+ const textProperty = this.getTextProperty(name, value);
+ if (textProperty) {
+ try {
+ await textProperty.setValue(value, "", true);
+ this.ruleView.on("property-value-updated", this.onRulePropertyUpdated);
+ } catch (error) {
+ // Because setValue() does an asynchronous call to the server, there is a chance
+ // the font editor was destroyed while we were waiting. If that happened, just
+ // bail out silently.
+ if (!this.document) {
+ return;
+ }
+
+ throw error;
+ }
+ }
+ }
+
+ /**
+ * Handler for changes of a font axis value coming from the FontEditor.
+ *
+ * @param {String} tag
+ * Tag name of the font axis.
+ * @param {Number} value
+ * Value of the font axis.
+ */
+ onAxisUpdate(tag, value) {
+ this.store.dispatch(updateAxis(tag, value));
+ const writer = this.getWriterForProperty(tag);
+ writer(value.toString());
+ }
+
+ /**
+ * Handler for changes of a CSS font property value coming from the FontEditor.
+ *
+ * @param {String} property
+ * CSS font property name.
+ * @param {Number} value
+ * CSS font property numeric value.
+ * @param {String|null} unit
+ * CSS unit or null
+ */
+ onFontPropertyUpdate(property, value, unit) {
+ value = unit !== null ? value + unit : value;
+ this.store.dispatch(updateFontProperty(property, value));
+ const writer = this.getWriterForProperty(property);
+ writer(value.toString());
+ }
+
+ /**
+ * Handler for selecting a font variation instance. Dispatches an action which updates
+ * the axes and their values as defined by that variation instance.
+ *
+ * @param {String} name
+ * Name of variation instance. (ex: Light, Regular, Ultrabold, etc.)
+ * @param {Array} values
+ * Array of objects with axes and values defined by the variation instance.
+ */
+ onInstanceChange(name, values) {
+ this.store.dispatch(applyInstance(name, values));
+ let writer;
+ values.map(obj => {
+ writer = this.getWriterForProperty(obj.axis);
+ writer(obj.value.toString());
+ });
+ }
+
+ /**
+ * Event handler for "new-node-front" event fired when a new node is selected in the
+ * markup view.
+ *
+ * Sets the selected node for which font faces and font properties will be
+ * shown in the font editor. If the selection is a text node, use its parent element.
+ *
+ * Triggers a refresh of the font editor and font overview if the panel is visible.
+ */
+ onNewNode() {
+ this.ruleView.off("property-value-updated", this.onRulePropertyUpdated);
+
+ // First, reset the selected node and page style front.
+ this.node = null;
+ this.pageStyle = null;
+
+ // Then attempt to assign a selected node according to its type.
+ const selection = this.inspector && this.inspector.selection;
+ if (selection && selection.isConnected()) {
+ if (selection.isElementNode()) {
+ this.node = selection.nodeFront;
+ } else if (selection.isTextNode()) {
+ this.node = selection.nodeFront.parentNode();
+ }
+
+ this.pageStyle = this.node.inspectorFront.pageStyle;
+ }
+
+ if (this.isPanelVisible()) {
+ Promise.all([this.update(), this.refreshFontEditor()])
+ .then(() => {
+ this.logTelemetryProbesOnNewNode();
+ })
+ .catch(e => console.error(e));
+ }
+ }
+
+ /**
+ * Handler for change in preview input.
+ */
+ onPreviewTextChange(value) {
+ this.store.dispatch(updatePreviewText(value));
+ this.update();
+ }
+
+ /**
+ * Handler for changes to any CSS font property value or variable font axis value coming
+ * from the Font Editor. This handler calls the appropriate method to preview the
+ * changes on the page and update the store.
+ *
+ * If the property parameter is not a recognized CSS font property name, assume it's a
+ * variable font axis name.
+ *
+ * @param {String} property
+ * CSS font property name or axis name
+ * @param {Number} value
+ * CSS font property value or axis value
+ * @param {String|undefined} fromUnit
+ * Optional CSS unit to convert from
+ * @param {String|undefined} toUnit
+ * Optional CSS unit to convert to
+ */
+ async onPropertyChange(property, value, fromUnit, toUnit) {
+ if (FONT_PROPERTIES.includes(property)) {
+ let unit = fromUnit;
+
+ // Strict checks because "line-height" value may be unitless (empty string).
+ if (toUnit !== undefined && fromUnit !== undefined) {
+ value = await this.convertUnits(property, value, fromUnit, toUnit);
+ unit = toUnit;
+ }
+
+ this.onFontPropertyUpdate(property, value, unit);
+ } else {
+ this.onAxisUpdate(property, value);
+ }
+ }
+
+ /**
+ * Handler for "property-value-updated" event emitted from the rule view whenever a
+ * property value changes. Ignore changes to properties unrelated to the font editor.
+ *
+ * @param {Object} eventData
+ * Object with the property name and value and origin rule.
+ * Example: { name: "font-size", value: "1em", rule: Object }
+ */
+ async onRulePropertyUpdated(eventData) {
+ if (!this.selectedRule || !FONT_PROPERTIES.includes(eventData.property)) {
+ return;
+ }
+
+ if (this.isPanelVisible()) {
+ await this.refreshFontEditor();
+ }
+ }
+
+ /**
+ * 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
+ * Optional. Default `true`. Whether or not to restrict revealing the font
+ * just to the current element selection.
+ */
+ async onToggleFontHighlight(font, show, isForCurrentElement = true) {
+ if (!this.fontsHighlighter) {
+ try {
+ this.fontsHighlighter =
+ await this.inspector.inspectorFront.getHighlighterByType(
+ "FontsHighlighter"
+ );
+ } catch (e) {
+ // the FontsHighlighter won't be available when debugging a XUL document.
+ // Silently fail here and prevent any future calls to the function.
+ this.onToggleFontHighlight = () => {};
+ return;
+ }
+ }
+
+ try {
+ if (show) {
+ const node = isForCurrentElement
+ ? this.node
+ : this.node.walkerFront.rootNode;
+
+ await this.fontsHighlighter.show(node, {
+ CSSFamilyName: font.CSSFamilyName,
+ name: font.name,
+ });
+ } else {
+ await this.fontsHighlighter.hide();
+ }
+ } catch (e) {
+ // Silently handle protocol errors here, because these might be called during
+ // shutdown of the browser or devtools, and we don't care if they fail.
+ }
+ }
+
+ /**
+ * Handler for the "theme-switched" event.
+ */
+ onThemeChanged(frame) {
+ if (frame === this.document.defaultView) {
+ this.update();
+ }
+ }
+
+ /**
+ * Update the state of the font editor with:
+ * - the fonts which apply to the current node;
+ * - the computed style CSS font properties of the current node.
+ *
+ * This method is called:
+ * - when a new node is selected;
+ * - when any property is changed in the Rule view.
+ * For the latter case, we compare between the latest computed style font properties
+ * and the ones already in the store to decide if to update the font editor state.
+ */
+ async refreshFontEditor() {
+ if (!this.node) {
+ this.store.dispatch(resetFontEditor());
+ return;
+ }
+
+ const options = {};
+ if (this.pageStyle.supportsFontVariations) {
+ options.includeVariations = true;
+ }
+
+ const fonts = await this.getFontsForNode(this.node, options);
+
+ try {
+ // Get computed styles for the selected node, but filter by CSS font properties.
+ this.nodeComputedStyle = await this.pageStyle.getComputed(this.node, {
+ filterProperties: FONT_PROPERTIES,
+ });
+ } catch (e) {
+ // Because getComputed is async, there is a chance the font editor was
+ // destroyed while we were waiting. If that happened, just bail out
+ // silently.
+ if (!this.document) {
+ return;
+ }
+
+ throw e;
+ }
+
+ if (!this.nodeComputedStyle || !fonts.length) {
+ this.store.dispatch(resetFontEditor());
+ this.inspector.emit("fonteditor-updated");
+ return;
+ }
+
+ // Clear any references to writer methods and CSS declarations because the node's
+ // styles may have changed since the last font editor refresh.
+ this.writers.clear();
+
+ // If the Rule panel is not visible, the selected element's rule models may not have
+ // been created yet. For example, in 2-pane mode when Fonts is opened as the default
+ // panel. Select the current node to force the Rule view to create the rule models.
+ if (!this.ruleViewTool.isPanelVisible()) {
+ await this.ruleView.selectElement(this.node, false);
+ }
+
+ // Select the node's inline style as the rule where to write property value changes.
+ this.selectedRule = this.ruleView.rules.find(
+ rule => rule.domRule.type === ELEMENT_STYLE
+ );
+
+ const properties = await this.getFontProperties();
+ // Assign writer methods to each axis defined in font-variation-settings.
+ const axes = parseFontVariationAxes(properties["font-variation-settings"]);
+ Object.keys(axes).map(axis => {
+ this.writers.set(axis, this.getWriterForAxis(axis));
+ });
+
+ this.store.dispatch(updateFontEditor(fonts, properties, this.node.actorID));
+ this.store.dispatch(setEditorDisabled(this.node.isPseudoElement));
+
+ this.inspector.emit("fonteditor-updated");
+ // Listen to manual changes in the Rule view that could update the Font Editor state
+ this.ruleView.on("property-value-updated", this.onRulePropertyUpdated);
+ }
+
+ async update() {
+ // Stop refreshing if the inspector or store is already destroyed.
+ if (!this.inspector || !this.store) {
+ return;
+ }
+
+ let allFonts = [];
+
+ if (!this.node) {
+ this.store.dispatch(updateFonts(allFonts));
+ return;
+ }
+
+ const { fontOptions } = this.store.getState();
+ const { previewText } = fontOptions;
+
+ const options = {
+ includePreviews: true,
+ // Coerce the type of `supportsFontVariations` to a boolean.
+ includeVariations: !!this.pageStyle.supportsFontVariations,
+ previewText,
+ previewFillStyle: getColor("body-color"),
+ };
+
+ // If there are no fonts used on the page, the result is an empty array.
+ allFonts = await this.getAllFonts(options);
+
+ // Augment each font object with a dataURI for an image with a sample of the font.
+ for (const font of [...allFonts]) {
+ font.previewUrl = await font.preview.data.string();
+ }
+
+ // Dispatch to the store if it hasn't been destroyed in the meantime.
+ this.store && this.store.dispatch(updateFonts(allFonts));
+ // Emit on the inspector if it hasn't been destroyed in the meantime.
+ // Pass the current node in the payload so that tests can check the update
+ // corresponds to the expected node.
+ this.inspector &&
+ this.inspector.emitForTests("fontinspector-updated", this.node);
+ }
+
+ /**
+ * Update the "font-variation-settings" CSS property with the state of all touched
+ * font variation axes which shouldn't be written to other CSS font properties.
+ */
+ updateFontVariationSettings() {
+ const fontEditor = this.store.getState().fontEditor;
+ const name = "font-variation-settings";
+ const value = Object.keys(fontEditor.axes)
+ // Pick only axes which are supposed to be written to font-variation-settings.
+ // Skip registered axes which should be written to a different CSS property.
+ .filter(tag => this.writers.get(tag) === this.updateFontVariationSettings)
+ // Build a string value for the "font-variation-settings" CSS property
+ .map(tag => `"${tag}" ${fontEditor.axes[tag]}`)
+ .join(", ");
+
+ this.updatePropertyValue(name, value);
+ }
+
+ /**
+ * Preview a property value (live) then sync the changes (debounced) to the Rule view.
+ *
+ * NOTE: Until Bug 1462591 is addressed, all changes are written to the element's inline
+ * style attribute. In this current scenario, Rule.previewPropertyValue()
+ * causes the whole inline style representation in the Rule view to update instead of
+ * just previewing the change on the element.
+ * We keep the debounced call to syncChanges() because it explicitly calls
+ * TextProperty.setValue() which performs other actions, including marking the property
+ * as "changed" in the Rule view with a green indicator.
+ *
+ * @param {String} name
+ * CSS property name
+ * @param {String}value
+ * CSS property value
+ */
+ updatePropertyValue(name, value) {
+ const textProperty = this.getTextProperty(name);
+
+ if (!textProperty) {
+ this.selectedRule.createProperty(name, value, "", true);
+ return;
+ }
+
+ if (textProperty.value === value) {
+ return;
+ }
+
+ // Prevent reacting to changes we caused.
+ this.ruleView.off("property-value-updated", this.onRulePropertyUpdated);
+ // Live preview font property changes on the page.
+ textProperty.rule
+ .previewPropertyValue(textProperty, value, "")
+ .catch(console.error);
+
+ // Sync Rule view with changes reflected on the page (debounced).
+ this.syncChanges(name, value);
+ }
+}
+
+module.exports = FontInspector;