diff options
Diffstat (limited to 'devtools/client/inspector/fonts/fonts.js')
-rw-r--r-- | devtools/client/inspector/fonts/fonts.js | 1112 |
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; |