Source code
Revision control
Copy as Markdown
Other Tools
/* 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
"use strict";
const {
gDevTools,
} = require("resource://devtools/client/framework/devtools.js");
const {
getCssVariableColor,
} = 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 { TYPES: HIGHLIGHTER_TYPES } = ChromeUtils.importESModule(
"resource://devtools/shared/highlighters.mjs"
);
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.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);
// 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.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;
}
/**
* 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) {
try {
if (show) {
const node = isForCurrentElement
? this.inspector.selection.nodeFront
: this.node.walkerFront.rootNode;
await this.inspector.highlighters.showHighlighterTypeForNode(
HIGHLIGHTER_TYPES.FONTS,
node,
{
CSSFamilyName: font.CSSFamilyName,
name: font.name,
}
);
} else {
await this.inspector.highlighters.hideHighlighterType(
HIGHLIGHTER_TYPES.FONTS
);
}
} 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: getCssVariableColor(
"--theme-body-color",
this.document.ownerGlobal
),
};
// 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.
*
* 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;