summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/fonts
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/inspector/fonts')
-rw-r--r--devtools/client/inspector/fonts/actions/font-editor.js70
-rw-r--r--devtools/client/inspector/fonts/actions/font-options.js21
-rw-r--r--devtools/client/inspector/fonts/actions/fonts.js21
-rw-r--r--devtools/client/inspector/fonts/actions/index.js42
-rw-r--r--devtools/client/inspector/fonts/actions/moz.build12
-rw-r--r--devtools/client/inspector/fonts/components/Font.js139
-rw-r--r--devtools/client/inspector/fonts/components/FontAxis.js75
-rw-r--r--devtools/client/inspector/fonts/components/FontEditor.js357
-rw-r--r--devtools/client/inspector/fonts/components/FontList.js82
-rw-r--r--devtools/client/inspector/fonts/components/FontName.js53
-rw-r--r--devtools/client/inspector/fonts/components/FontOrigin.js79
-rw-r--r--devtools/client/inspector/fonts/components/FontOverview.js80
-rw-r--r--devtools/client/inspector/fonts/components/FontPreview.js40
-rw-r--r--devtools/client/inspector/fonts/components/FontPreviewInput.js77
-rw-r--r--devtools/client/inspector/fonts/components/FontPropertyValue.js434
-rw-r--r--devtools/client/inspector/fonts/components/FontSize.js87
-rw-r--r--devtools/client/inspector/fonts/components/FontStyle.js69
-rw-r--r--devtools/client/inspector/fonts/components/FontWeight.js45
-rw-r--r--devtools/client/inspector/fonts/components/FontsApp.js71
-rw-r--r--devtools/client/inspector/fonts/components/LetterSpacing.js105
-rw-r--r--devtools/client/inspector/fonts/components/LineHeight.js101
-rw-r--r--devtools/client/inspector/fonts/components/moz.build24
-rw-r--r--devtools/client/inspector/fonts/fonts.js1112
-rw-r--r--devtools/client/inspector/fonts/moz.build19
-rw-r--r--devtools/client/inspector/fonts/reducers/font-editor.js157
-rw-r--r--devtools/client/inspector/fonts/reducers/font-options.js27
-rw-r--r--devtools/client/inspector/fonts/reducers/fonts.js28
-rw-r--r--devtools/client/inspector/fonts/reducers/moz.build11
-rw-r--r--devtools/client/inspector/fonts/test/OstrichLicense.txt41
-rw-r--r--devtools/client/inspector/fonts/test/browser.toml55
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector.js94
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_all-fonts.js82
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_copy-URL.js27
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_edit-previews.js70
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_editor-font-size-conversion.js85
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_editor-keywords.js44
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_editor-letter-spacing-conversion.js71
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_editor-values.js36
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_expand-css-code.js34
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_font-type-telemetry.js20
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_input-element-used-font.js23
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_no-fonts.js25
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_reveal-in-page.js100
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_text-node.js35
-rw-r--r--devtools/client/inspector/fonts/test/browser_fontinspector_theme-change.js66
-rw-r--r--devtools/client/inspector/fonts/test/doc_browser_fontinspector.html67
-rw-r--r--devtools/client/inspector/fonts/test/doc_browser_fontinspector_iframe.html5
-rw-r--r--devtools/client/inspector/fonts/test/head.js277
-rw-r--r--devtools/client/inspector/fonts/test/ostrich-black.ttfbin0 -> 12872 bytes
-rw-r--r--devtools/client/inspector/fonts/test/ostrich-regular.ttfbin0 -> 12476 bytes
-rw-r--r--devtools/client/inspector/fonts/test/test_iframe.html11
-rw-r--r--devtools/client/inspector/fonts/types.js109
-rw-r--r--devtools/client/inspector/fonts/utils/font-utils.js111
-rw-r--r--devtools/client/inspector/fonts/utils/l10n.js14
-rw-r--r--devtools/client/inspector/fonts/utils/moz.build10
55 files changed, 4950 insertions, 0 deletions
diff --git a/devtools/client/inspector/fonts/actions/font-editor.js b/devtools/client/inspector/fonts/actions/font-editor.js
new file mode 100644
index 0000000000..0542604d7b
--- /dev/null
+++ b/devtools/client/inspector/fonts/actions/font-editor.js
@@ -0,0 +1,70 @@
+/* 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 {
+ APPLY_FONT_VARIATION_INSTANCE,
+ RESET_EDITOR,
+ SET_FONT_EDITOR_DISABLED,
+ UPDATE_AXIS_VALUE,
+ UPDATE_EDITOR_STATE,
+ UPDATE_PROPERTY_VALUE,
+ UPDATE_WARNING_MESSAGE,
+} = require("resource://devtools/client/inspector/fonts/actions/index.js");
+
+module.exports = {
+ resetFontEditor() {
+ return {
+ type: RESET_EDITOR,
+ };
+ },
+
+ setEditorDisabled(disabled = false) {
+ return {
+ type: SET_FONT_EDITOR_DISABLED,
+ disabled,
+ };
+ },
+
+ applyInstance(name, values) {
+ return {
+ type: APPLY_FONT_VARIATION_INSTANCE,
+ name,
+ values,
+ };
+ },
+
+ updateAxis(axis, value) {
+ return {
+ type: UPDATE_AXIS_VALUE,
+ axis,
+ value,
+ };
+ },
+
+ updateFontEditor(fonts, properties = {}, id = "") {
+ return {
+ type: UPDATE_EDITOR_STATE,
+ fonts,
+ properties,
+ id,
+ };
+ },
+
+ updateFontProperty(property, value) {
+ return {
+ type: UPDATE_PROPERTY_VALUE,
+ property,
+ value,
+ };
+ },
+
+ updateWarningMessage(warning) {
+ return {
+ type: UPDATE_WARNING_MESSAGE,
+ warning,
+ };
+ },
+};
diff --git a/devtools/client/inspector/fonts/actions/font-options.js b/devtools/client/inspector/fonts/actions/font-options.js
new file mode 100644
index 0000000000..8dfe101edd
--- /dev/null
+++ b/devtools/client/inspector/fonts/actions/font-options.js
@@ -0,0 +1,21 @@
+/* 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 {
+ UPDATE_PREVIEW_TEXT,
+} = require("resource://devtools/client/inspector/fonts/actions/index.js");
+
+module.exports = {
+ /**
+ * Update the preview text in the font inspector
+ */
+ updatePreviewText(previewText) {
+ return {
+ type: UPDATE_PREVIEW_TEXT,
+ previewText,
+ };
+ },
+};
diff --git a/devtools/client/inspector/fonts/actions/fonts.js b/devtools/client/inspector/fonts/actions/fonts.js
new file mode 100644
index 0000000000..5682e65521
--- /dev/null
+++ b/devtools/client/inspector/fonts/actions/fonts.js
@@ -0,0 +1,21 @@
+/* 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 {
+ UPDATE_FONTS,
+} = require("resource://devtools/client/inspector/fonts/actions/index.js");
+
+module.exports = {
+ /**
+ * Update the list of fonts in the font inspector
+ */
+ updateFonts(allFonts) {
+ return {
+ type: UPDATE_FONTS,
+ allFonts,
+ };
+ },
+};
diff --git a/devtools/client/inspector/fonts/actions/index.js b/devtools/client/inspector/fonts/actions/index.js
new file mode 100644
index 0000000000..21597b8c41
--- /dev/null
+++ b/devtools/client/inspector/fonts/actions/index.js
@@ -0,0 +1,42 @@
+/* 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 { createEnum } = require("resource://devtools/client/shared/enum.js");
+
+createEnum(
+ [
+ // Reset font editor to intial state.
+ "RESET_EDITOR",
+
+ // Set the font editor disabled state which prevents users from interacting with inputs.
+ "SET_FONT_EDITOR_DISABLED",
+
+ // Apply the variation settings of a font instance.
+ "APPLY_FONT_VARIATION_INSTANCE",
+
+ // Update the custom font variation instance with the current axes values.
+ "UPDATE_CUSTOM_INSTANCE",
+
+ // Update the value of a variable font axis.
+ "UPDATE_AXIS_VALUE",
+
+ // Update font editor with applicable fonts and user-defined CSS font properties.
+ "UPDATE_EDITOR_STATE",
+
+ // Update the list of fonts.
+ "UPDATE_FONTS",
+
+ // Update the preview text.
+ "UPDATE_PREVIEW_TEXT",
+
+ // Update the value of a CSS font property
+ "UPDATE_PROPERTY_VALUE",
+
+ // Update the warning message with the reason for not showing the font editor
+ "UPDATE_WARNING_MESSAGE",
+ ],
+ module.exports
+);
diff --git a/devtools/client/inspector/fonts/actions/moz.build b/devtools/client/inspector/fonts/actions/moz.build
new file mode 100644
index 0000000000..31452af580
--- /dev/null
+++ b/devtools/client/inspector/fonts/actions/moz.build
@@ -0,0 +1,12 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "font-editor.js",
+ "font-options.js",
+ "fonts.js",
+ "index.js",
+)
diff --git a/devtools/client/inspector/fonts/components/Font.js b/devtools/client/inspector/fonts/components/Font.js
new file mode 100644
index 0000000000..c761a3d2f1
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/Font.js
@@ -0,0 +1,139 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const FontName = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontName.js")
+);
+const FontOrigin = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontOrigin.js")
+);
+const FontPreview = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontPreview.js")
+);
+
+const Types = require("resource://devtools/client/inspector/fonts/types.js");
+
+class Font extends PureComponent {
+ static get propTypes() {
+ return {
+ font: PropTypes.shape(Types.font).isRequired,
+ onPreviewClick: PropTypes.func,
+ onToggleFontHighlight: PropTypes.func.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isFontFaceRuleExpanded: false,
+ };
+
+ this.onFontFaceRuleToggle = this.onFontFaceRuleToggle.bind(this);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(newProps) {
+ if (this.props.font.name === newProps.font.name) {
+ return;
+ }
+
+ this.setState({
+ isFontFaceRuleExpanded: false,
+ });
+ }
+
+ onFontFaceRuleToggle(event) {
+ this.setState({
+ isFontFaceRuleExpanded: !this.state.isFontFaceRuleExpanded,
+ });
+ event.stopPropagation();
+ }
+
+ renderFontCSSCode(rule, ruleText) {
+ if (!rule) {
+ return null;
+ }
+
+ // Cut the rule text in 3 parts: the selector, the declarations, the closing brace.
+ // This way we can collapse the declarations by default and display an expander icon
+ // to expand them again.
+ const leading = ruleText.substring(0, ruleText.indexOf("{") + 1);
+ const body = ruleText.substring(
+ ruleText.indexOf("{") + 1,
+ ruleText.lastIndexOf("}")
+ );
+ const trailing = ruleText.substring(ruleText.lastIndexOf("}"));
+
+ const { isFontFaceRuleExpanded } = this.state;
+
+ return dom.pre(
+ {
+ className: "font-css-code",
+ },
+ this.renderFontCSSCodeTwisty(),
+ leading,
+ isFontFaceRuleExpanded
+ ? body
+ : dom.span({
+ className: "font-css-code-expander",
+ onClick: this.onFontFaceRuleToggle,
+ }),
+ trailing
+ );
+ }
+
+ renderFontCSSCodeTwisty() {
+ const { isFontFaceRuleExpanded } = this.state;
+
+ const attributes = {
+ className: "theme-twisty",
+ onClick: this.onFontFaceRuleToggle,
+ };
+ if (isFontFaceRuleExpanded) {
+ attributes.open = "true";
+ }
+
+ return dom.span(attributes);
+ }
+
+ renderFontFamilyName(family) {
+ if (!family) {
+ return null;
+ }
+
+ return dom.div({ className: "font-family-name" }, family);
+ }
+
+ render() {
+ const { font, onPreviewClick, onToggleFontHighlight } = this.props;
+
+ const { CSSFamilyName, previewUrl, rule, ruleText } = font;
+
+ return dom.li(
+ {
+ className: "font",
+ },
+ dom.div(
+ {},
+ this.renderFontFamilyName(CSSFamilyName),
+ FontName({ font, onToggleFontHighlight })
+ ),
+ FontOrigin({ font }),
+ FontPreview({ onPreviewClick, previewUrl }),
+ this.renderFontCSSCode(rule, ruleText)
+ );
+ }
+}
+
+module.exports = Font;
diff --git a/devtools/client/inspector/fonts/components/FontAxis.js b/devtools/client/inspector/fonts/components/FontAxis.js
new file mode 100644
index 0000000000..f695c06b29
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontAxis.js
@@ -0,0 +1,75 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const FontPropertyValue = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontPropertyValue.js")
+);
+
+const Types = require("resource://devtools/client/inspector/fonts/types.js");
+
+class FontAxis extends PureComponent {
+ static get propTypes() {
+ return {
+ axis: PropTypes.shape(Types.fontVariationAxis),
+ disabled: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.number.isRequired,
+ };
+ }
+
+ /**
+ * Naive implementation to get increment step for variable font axis that ensures
+ * fine grained control based on range of values between min and max.
+ *
+ * @param {Number} min
+ * Minumum value for range.
+ * @param {Number} max
+ * Maximum value for range.
+ * @return {Number}
+ * Step value used in range input for font axis.
+ */
+ getAxisStep(min, max) {
+ let step = 1;
+ const delta = parseInt(max, 10) - parseInt(min, 10);
+
+ if (delta <= 1) {
+ step = 0.001;
+ } else if (delta <= 10) {
+ step = 0.01;
+ } else if (delta <= 100) {
+ step = 0.1;
+ }
+
+ return step;
+ }
+
+ render() {
+ const { axis, value, onChange } = this.props;
+
+ return FontPropertyValue({
+ className: "font-control-axis",
+ disabled: this.props.disabled,
+ label: axis.name,
+ min: axis.minValue,
+ minLabel: true,
+ max: axis.maxValue,
+ maxLabel: true,
+ name: axis.tag,
+ nameLabel: true,
+ onChange,
+ step: this.getAxisStep(axis.minValue, axis.maxValue),
+ value,
+ });
+ }
+}
+
+module.exports = FontAxis;
diff --git a/devtools/client/inspector/fonts/components/FontEditor.js b/devtools/client/inspector/fonts/components/FontEditor.js
new file mode 100644
index 0000000000..b98118c9fc
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontEditor.js
@@ -0,0 +1,357 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const FontAxis = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontAxis.js")
+);
+const FontName = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontName.js")
+);
+const FontSize = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontSize.js")
+);
+const FontStyle = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontStyle.js")
+);
+const FontWeight = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontWeight.js")
+);
+const LetterSpacing = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/LetterSpacing.js")
+);
+const LineHeight = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/LineHeight.js")
+);
+
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/fonts/utils/l10n.js");
+const Types = require("resource://devtools/client/inspector/fonts/types.js");
+
+// Maximum number of font families to be shown by default. Any others will be hidden
+// under a collapsed <details> element with a toggle to reveal them.
+const MAX_FONTS = 3;
+
+class FontEditor extends PureComponent {
+ static get propTypes() {
+ return {
+ fontEditor: PropTypes.shape(Types.fontEditor).isRequired,
+ onInstanceChange: PropTypes.func.isRequired,
+ onPropertyChange: PropTypes.func.isRequired,
+ onToggleFontHighlight: PropTypes.func.isRequired,
+ };
+ }
+
+ /**
+ * Get an array of FontAxis components with editing controls for of the given variable
+ * font axes. If no axes were given, return null.
+ * If an axis' value was declared on the font-variation-settings CSS property or was
+ * changed using the font editor, use that value, otherwise use the axis default.
+ *
+ * @param {Array} fontAxes
+ * Array of font axis instances
+ * @param {Object} editedAxes
+ * Object with axes and values edited by the user or defined in the CSS
+ * declaration for font-variation-settings.
+ * @return {Array|null}
+ */
+ renderAxes(fontAxes = [], editedAxes) {
+ if (!fontAxes.length) {
+ return null;
+ }
+
+ return fontAxes.map(axis => {
+ return FontAxis({
+ key: axis.tag,
+ axis,
+ disabled: this.props.fontEditor.disabled,
+ onChange: this.props.onPropertyChange,
+ minLabel: true,
+ maxLabel: true,
+ value: editedAxes[axis.tag] || axis.defaultValue,
+ });
+ });
+ }
+
+ /**
+ * Render fonts used on the selected node grouped by font-family.
+ *
+ * @param {Array} fonts
+ * Fonts used on selected node.
+ * @return {DOMNode}
+ */
+ renderUsedFonts(fonts) {
+ if (!fonts.length) {
+ return null;
+ }
+
+ // Group fonts by family name.
+ const fontGroups = fonts.reduce((acc, font) => {
+ const family = font.CSSFamilyName.toString();
+ acc[family] = acc[family] || [];
+ acc[family].push(font);
+ return acc;
+ }, {});
+
+ const renderedFontGroups = Object.keys(fontGroups).map(family => {
+ return this.renderFontGroup(family, fontGroups[family]);
+ });
+
+ const topFontsList = renderedFontGroups.slice(0, MAX_FONTS);
+ const moreFontsList = renderedFontGroups.slice(
+ MAX_FONTS,
+ renderedFontGroups.length
+ );
+
+ const moreFonts = !moreFontsList.length
+ ? null
+ : dom.details(
+ {},
+ dom.summary(
+ {},
+ dom.span(
+ { className: "label-open" },
+ getStr("fontinspector.showMore")
+ ),
+ dom.span(
+ { className: "label-close" },
+ getStr("fontinspector.showLess")
+ )
+ ),
+ moreFontsList
+ );
+
+ return dom.label(
+ {
+ className: "font-control font-control-used-fonts",
+ },
+ dom.span(
+ {
+ className: "font-control-label",
+ },
+ getStr("fontinspector.fontsUsedLabel")
+ ),
+ dom.div(
+ {
+ className: "font-control-box",
+ },
+ topFontsList,
+ moreFonts
+ )
+ );
+ }
+
+ renderFontGroup(family, fonts = []) {
+ const group = fonts.map(font => {
+ return FontName({
+ key: font.name,
+ font,
+ onToggleFontHighlight: this.props.onToggleFontHighlight,
+ });
+ });
+
+ return dom.div(
+ {
+ key: family,
+ className: "font-group",
+ },
+ dom.div(
+ {
+ className: "font-family-name",
+ },
+ family
+ ),
+ group
+ );
+ }
+
+ renderFontSize(value) {
+ return (
+ value !== null &&
+ FontSize({
+ key: `${this.props.fontEditor.id}:font-size`,
+ disabled: this.props.fontEditor.disabled,
+ onChange: this.props.onPropertyChange,
+ value,
+ })
+ );
+ }
+
+ renderLineHeight(value) {
+ return (
+ value !== null &&
+ LineHeight({
+ key: `${this.props.fontEditor.id}:line-height`,
+ disabled: this.props.fontEditor.disabled,
+ onChange: this.props.onPropertyChange,
+ value,
+ })
+ );
+ }
+
+ renderLetterSpacing(value) {
+ return (
+ value !== null &&
+ LetterSpacing({
+ key: `${this.props.fontEditor.id}:letter-spacing`,
+ disabled: this.props.fontEditor.disabled,
+ onChange: this.props.onPropertyChange,
+ value,
+ })
+ );
+ }
+
+ renderFontStyle(value) {
+ return (
+ value &&
+ FontStyle({
+ onChange: this.props.onPropertyChange,
+ disabled: this.props.fontEditor.disabled,
+ value,
+ })
+ );
+ }
+
+ renderFontWeight(value) {
+ return (
+ value !== null &&
+ FontWeight({
+ onChange: this.props.onPropertyChange,
+ disabled: this.props.fontEditor.disabled,
+ value,
+ })
+ );
+ }
+
+ /**
+ * Get a dropdown which allows selecting between variation instances defined by a font.
+ *
+ * @param {Array} fontInstances
+ * Named variation instances as provided with the font file.
+ * @param {Object} selectedInstance
+ * Object with information about the currently selected variation instance.
+ * Example:
+ * {
+ * name: "Custom",
+ * values: []
+ * }
+ * @return {DOMNode}
+ */
+ renderInstances(fontInstances = [], selectedInstance = {}) {
+ // Append a "Custom" instance entry which represents the latest manual axes changes.
+ const customInstance = {
+ name: getStr("fontinspector.customInstanceName"),
+ values: this.props.fontEditor.customInstanceValues,
+ };
+ fontInstances = [...fontInstances, customInstance];
+
+ // Generate the <option> elements for the dropdown.
+ const instanceOptions = fontInstances.map(instance =>
+ dom.option(
+ {
+ key: instance.name,
+ value: instance.name,
+ },
+ instance.name
+ )
+ );
+
+ // Generate the dropdown.
+ const instanceSelect = dom.select(
+ {
+ className: "font-control-input font-value-select",
+ value: selectedInstance.name || customInstance.name,
+ onChange: e => {
+ const instance = fontInstances.find(
+ inst => e.target.value === inst.name
+ );
+ instance &&
+ this.props.onInstanceChange(instance.name, instance.values);
+ },
+ },
+ instanceOptions
+ );
+
+ return dom.label(
+ {
+ className: "font-control",
+ },
+ dom.span(
+ {
+ className: "font-control-label",
+ },
+ getStr("fontinspector.fontInstanceLabel")
+ ),
+ instanceSelect
+ );
+ }
+
+ renderWarning(warning) {
+ return dom.div(
+ {
+ id: "font-editor",
+ },
+ dom.div(
+ {
+ className: "devtools-sidepanel-no-result",
+ },
+ warning
+ )
+ );
+ }
+
+ render() {
+ const { fontEditor } = this.props;
+ const { fonts, axes, instance, properties, warning } = fontEditor;
+ // Pick the first font to show editor controls regardless of how many fonts are used.
+ const font = fonts[0];
+ const hasFontAxes = font?.variationAxes;
+ const hasFontInstances = font?.variationInstances?.length > 0;
+ const hasSlantOrItalicAxis = font?.variationAxes?.find(axis => {
+ return axis.tag === "slnt" || axis.tag === "ital";
+ });
+ const hasWeightAxis = font?.variationAxes?.find(axis => {
+ return axis.tag === "wght";
+ });
+
+ // Show the empty state with a warning message when a used font was not found.
+ if (!font) {
+ return this.renderWarning(warning);
+ }
+
+ return dom.div(
+ {
+ id: "font-editor",
+ },
+ // Always render UI for used fonts.
+ this.renderUsedFonts(fonts),
+ // Render UI for font variation instances if they are defined.
+ hasFontInstances &&
+ this.renderInstances(font.variationInstances, instance),
+ // Always render UI for font size.
+ this.renderFontSize(properties["font-size"]),
+ // Always render UI for line height.
+ this.renderLineHeight(properties["line-height"]),
+ // Always render UI for letter spacing.
+ this.renderLetterSpacing(properties["letter-spacing"]),
+ // Render UI for font weight if no "wght" registered axis is defined.
+ !hasWeightAxis && this.renderFontWeight(properties["font-weight"]),
+ // Render UI for font style if no "slnt" or "ital" registered axis is defined.
+ !hasSlantOrItalicAxis && this.renderFontStyle(properties["font-style"]),
+ // Render UI for each variable font axis if any are defined.
+ hasFontAxes && this.renderAxes(font.variationAxes, axes)
+ );
+ }
+}
+
+module.exports = FontEditor;
diff --git a/devtools/client/inspector/fonts/components/FontList.js b/devtools/client/inspector/fonts/components/FontList.js
new file mode 100644
index 0000000000..3199ff583e
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontList.js
@@ -0,0 +1,82 @@
+/* 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 {
+ createElement,
+ createFactory,
+ createRef,
+ Fragment,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const Font = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/Font.js")
+);
+const FontPreviewInput = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontPreviewInput.js")
+);
+
+const Types = require("resource://devtools/client/inspector/fonts/types.js");
+
+class FontList extends PureComponent {
+ static get propTypes() {
+ return {
+ fontOptions: PropTypes.shape(Types.fontOptions).isRequired,
+ fonts: PropTypes.arrayOf(PropTypes.shape(Types.font)).isRequired,
+ onPreviewTextChange: PropTypes.func.isRequired,
+ onToggleFontHighlight: PropTypes.func.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.onPreviewClick = this.onPreviewClick.bind(this);
+ this.previewInputRef = createRef();
+ }
+
+ /**
+ * Handler for clicks on the font preview image.
+ * Requests the FontPreviewInput component, if one exists, to focus its input field.
+ */
+ onPreviewClick() {
+ this.previewInputRef.current && this.previewInputRef.current.focus();
+ }
+
+ render() {
+ const { fonts, fontOptions, onPreviewTextChange, onToggleFontHighlight } =
+ this.props;
+
+ const { previewText } = fontOptions;
+ const { onPreviewClick } = this;
+
+ const list = dom.ul(
+ {
+ className: "fonts-list",
+ },
+ fonts.map((font, i) =>
+ Font({
+ key: i,
+ font,
+ onPreviewClick,
+ onToggleFontHighlight,
+ })
+ )
+ );
+
+ const previewInput = FontPreviewInput({
+ ref: this.previewInputRef,
+ onPreviewTextChange,
+ previewText,
+ });
+
+ return createElement(Fragment, null, previewInput, list);
+ }
+}
+
+module.exports = FontList;
diff --git a/devtools/client/inspector/fonts/components/FontName.js b/devtools/client/inspector/fonts/components/FontName.js
new file mode 100644
index 0000000000..d68f91293c
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontName.js
@@ -0,0 +1,53 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const Types = require("resource://devtools/client/inspector/fonts/types.js");
+
+class FontName extends PureComponent {
+ static get propTypes() {
+ return {
+ font: PropTypes.shape(Types.font).isRequired,
+ onToggleFontHighlight: PropTypes.func.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.onNameMouseOver = this.onNameMouseOver.bind(this);
+ this.onNameMouseOut = this.onNameMouseOut.bind(this);
+ }
+
+ onNameMouseOver() {
+ const { font, onToggleFontHighlight } = this.props;
+
+ onToggleFontHighlight(font, true);
+ }
+
+ onNameMouseOut() {
+ const { font, onToggleFontHighlight } = this.props;
+
+ onToggleFontHighlight(font, false);
+ }
+
+ render() {
+ return dom.span(
+ {
+ className: "font-name",
+ onMouseOver: this.onNameMouseOver,
+ onMouseOut: this.onNameMouseOut,
+ },
+ this.props.font.name
+ );
+ }
+}
+
+module.exports = FontName;
diff --git a/devtools/client/inspector/fonts/components/FontOrigin.js b/devtools/client/inspector/fonts/components/FontOrigin.js
new file mode 100644
index 0000000000..aa9a6cdebc
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontOrigin.js
@@ -0,0 +1,79 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+loader.lazyRequireGetter(
+ this,
+ "clipboardHelper",
+ "resource://devtools/shared/platform/clipboard.js"
+);
+
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/fonts/utils/l10n.js");
+const Types = require("resource://devtools/client/inspector/fonts/types.js");
+
+class FontOrigin extends PureComponent {
+ static get propTypes() {
+ return {
+ font: PropTypes.shape(Types.font).isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.onCopyURL = this.onCopyURL.bind(this);
+ }
+
+ clipTitle(title, maxLength = 512) {
+ if (title.length > maxLength) {
+ return title.substring(0, maxLength - 2) + "…";
+ }
+ return title;
+ }
+
+ onCopyURL() {
+ clipboardHelper.copyString(this.props.font.URI);
+ }
+
+ render() {
+ const url = this.props.font.URI;
+
+ if (!url) {
+ return dom.p(
+ {
+ className: "font-origin system",
+ },
+ getStr("fontinspector.system")
+ );
+ }
+
+ return dom.p(
+ {
+ className: "font-origin remote",
+ },
+ dom.span(
+ {
+ className: "url",
+ title: this.clipTitle(url),
+ },
+ url
+ ),
+ dom.button({
+ className: "copy-icon",
+ onClick: this.onCopyURL,
+ title: getStr("fontinspector.copyURL"),
+ })
+ );
+ }
+}
+
+module.exports = FontOrigin;
diff --git a/devtools/client/inspector/fonts/components/FontOverview.js b/devtools/client/inspector/fonts/components/FontOverview.js
new file mode 100644
index 0000000000..82da06aaac
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontOverview.js
@@ -0,0 +1,80 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const Accordion = createFactory(
+ require("resource://devtools/client/shared/components/Accordion.js")
+);
+const FontList = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontList.js")
+);
+
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/fonts/utils/l10n.js");
+const Types = require("resource://devtools/client/inspector/fonts/types.js");
+
+class FontOverview extends PureComponent {
+ static get propTypes() {
+ return {
+ fontData: PropTypes.shape(Types.fontData).isRequired,
+ fontOptions: PropTypes.shape(Types.fontOptions).isRequired,
+ onPreviewTextChange: PropTypes.func.isRequired,
+ onToggleFontHighlight: PropTypes.func.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.onToggleFontHighlightGlobal = (font, show) => {
+ this.props.onToggleFontHighlight(font, show, false);
+ };
+ }
+
+ renderFonts() {
+ const { fontData, fontOptions, onPreviewTextChange } = this.props;
+
+ const fonts = fontData.allFonts;
+
+ if (!fonts.length) {
+ return null;
+ }
+
+ return Accordion({
+ items: [
+ {
+ header: getStr("fontinspector.allFontsOnPageHeader"),
+ id: "font-list-details",
+ component: FontList,
+ componentProps: {
+ fontOptions,
+ fonts,
+ onPreviewTextChange,
+ onToggleFontHighlight: this.onToggleFontHighlightGlobal,
+ },
+ opened: false,
+ },
+ ],
+ });
+ }
+
+ render() {
+ return dom.div(
+ {
+ id: "font-container",
+ },
+ this.renderFonts()
+ );
+ }
+}
+
+module.exports = FontOverview;
diff --git a/devtools/client/inspector/fonts/components/FontPreview.js b/devtools/client/inspector/fonts/components/FontPreview.js
new file mode 100644
index 0000000000..dda18845f3
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontPreview.js
@@ -0,0 +1,40 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const Types = require("resource://devtools/client/inspector/fonts/types.js");
+
+class FontPreview extends PureComponent {
+ static get propTypes() {
+ return {
+ onPreviewClick: PropTypes.func,
+ previewUrl: Types.font.previewUrl.isRequired,
+ };
+ }
+
+ static get defaultProps() {
+ return {
+ onPreviewClick: () => {},
+ };
+ }
+
+ render() {
+ const { onPreviewClick, previewUrl } = this.props;
+
+ return dom.img({
+ className: "font-preview",
+ onClick: onPreviewClick,
+ src: previewUrl,
+ });
+ }
+}
+
+module.exports = FontPreview;
diff --git a/devtools/client/inspector/fonts/components/FontPreviewInput.js b/devtools/client/inspector/fonts/components/FontPreviewInput.js
new file mode 100644
index 0000000000..80ae15f778
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontPreviewInput.js
@@ -0,0 +1,77 @@
+/* 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 {
+ createRef,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const Types = require("resource://devtools/client/inspector/fonts/types.js");
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/fonts/utils/l10n.js");
+
+const PREVIEW_TEXT_MAX_LENGTH = 30;
+
+class FontPreviewInput extends PureComponent {
+ static get propTypes() {
+ return {
+ onPreviewTextChange: PropTypes.func.isRequired,
+ previewText: Types.fontOptions.previewText.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.onChange = this.onChange.bind(this);
+ this.onFocus = this.onFocus.bind(this);
+ this.inputRef = createRef();
+
+ this.state = {
+ value: this.props.previewText,
+ };
+ }
+
+ onChange(e) {
+ const value = e.target.value;
+ this.props.onPreviewTextChange(value);
+
+ this.setState(prevState => {
+ return { ...prevState, value };
+ });
+ }
+
+ onFocus(e) {
+ e.target.select();
+ }
+
+ focus() {
+ this.inputRef.current.focus();
+ }
+
+ render() {
+ return dom.div(
+ {
+ id: "font-preview-input-container",
+ },
+ dom.input({
+ className: "devtools-searchinput",
+ onChange: this.onChange,
+ onFocus: this.onFocus,
+ maxLength: PREVIEW_TEXT_MAX_LENGTH,
+ placeholder: getStr("fontinspector.previewTextPlaceholder"),
+ ref: this.inputRef,
+ type: "text",
+ value: this.state.value,
+ })
+ );
+ }
+}
+
+module.exports = FontPreviewInput;
diff --git a/devtools/client/inspector/fonts/components/FontPropertyValue.js b/devtools/client/inspector/fonts/components/FontPropertyValue.js
new file mode 100644
index 0000000000..59920818c4
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontPropertyValue.js
@@ -0,0 +1,434 @@
+/* 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 {
+ createElement,
+ Fragment,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const {
+ toFixed,
+} = require("resource://devtools/client/inspector/fonts/utils/font-utils.js");
+
+class FontPropertyValue extends PureComponent {
+ static get propTypes() {
+ return {
+ // Whether to allow input values above the value defined by the `max` prop.
+ allowOverflow: PropTypes.bool,
+ // Whether to allow input values below the value defined by the `min` prop.
+ allowUnderflow: PropTypes.bool,
+ className: PropTypes.string,
+ defaultValue: PropTypes.number,
+ disabled: PropTypes.bool.isRequired,
+ label: PropTypes.string.isRequired,
+ min: PropTypes.number.isRequired,
+ // Whether to show the `min` prop value as a label.
+ minLabel: PropTypes.bool,
+ max: PropTypes.number.isRequired,
+ // Whether to show the `max` prop value as a label.
+ maxLabel: PropTypes.bool,
+ name: PropTypes.string.isRequired,
+ // Whether to show the `name` prop value as an extra label (used to show axis tags).
+ nameLabel: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ step: PropTypes.number,
+ // Whether to show the value input field.
+ showInput: PropTypes.bool,
+ // Whether to show the unit select dropdown.
+ showUnit: PropTypes.bool,
+ unit: PropTypes.string,
+ unitOptions: PropTypes.array,
+ value: PropTypes.number,
+ valueLabel: PropTypes.string,
+ };
+ }
+
+ static get defaultProps() {
+ return {
+ allowOverflow: false,
+ allowUnderflow: false,
+ className: "",
+ minLabel: false,
+ maxLabel: false,
+ nameLabel: false,
+ step: 1,
+ showInput: true,
+ showUnit: true,
+ unit: null,
+ unitOptions: [],
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ // Whether the user is dragging the slider thumb or pressing on the numeric stepper.
+ interactive: false,
+ // Snapshot of the value from props before the user starts editing the number input.
+ // Used to restore the value when the input is left invalid.
+ initialValue: this.props.value,
+ // Snapshot of the value from props. Reconciled with props on blur.
+ // Used while the user is interacting with the inputs.
+ value: this.props.value,
+ };
+
+ this.onBlur = this.onBlur.bind(this);
+ this.onChange = this.onChange.bind(this);
+ this.onFocus = this.onFocus.bind(this);
+ this.onMouseDown = this.onMouseDown.bind(this);
+ this.onMouseUp = this.onMouseUp.bind(this);
+ this.onUnitChange = this.onUnitChange.bind(this);
+ }
+
+ /**
+ * Given a `prop` key found on the component's props, check the matching `propLabel`.
+ * If `propLabel` is true, return the `prop` value; Otherwise, return null.
+ *
+ * @param {String} prop
+ * Key found on the component's props.
+ * @return {Number|null}
+ */
+ getPropLabel(prop) {
+ const label = this.props[`${prop}Label`];
+ // Decimal count used to limit numbers in labels.
+ const decimals = Math.abs(Math.log10(this.props.step));
+
+ return label ? toFixed(this.props[prop], decimals) : null;
+ }
+
+ /**
+ * Check if the given value is valid according to the constraints of this component.
+ * Ensure it is a number and that it does not go outside the min/max limits, unless
+ * allowed by the `allowOverflow` and `allowUnderflow` props.
+ *
+ * @param {Number} value
+ * Numeric value
+ * @return {Boolean}
+ * Whether the value conforms to the components contraints.
+ */
+ isValueValid(value) {
+ const { allowOverflow, allowUnderflow, min, max } = this.props;
+
+ if (typeof value !== "number" || isNaN(value)) {
+ return false;
+ }
+
+ // Ensure it does not go below minimum value, unless underflow is allowed.
+ if (min !== undefined && value < min && !allowUnderflow) {
+ return false;
+ }
+
+ // Ensure it does not go over maximum value, unless overflow is allowed.
+ if (max !== undefined && value > max && !allowOverflow) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Handler for "blur" events from the range and number input fields.
+ * Reconciles the value between internal state and props.
+ * Marks the input as non-interactive so it may update in response to changes in props.
+ */
+ onBlur() {
+ const isValid = this.isValueValid(this.state.value);
+ let value;
+
+ if (isValid) {
+ value = this.state.value;
+ } else if (this.state.value !== null) {
+ value = Math.max(
+ this.props.min,
+ Math.min(this.state.value, this.props.max)
+ );
+ } else {
+ value = this.state.initialValue;
+ }
+
+ // Avoid updating the value if a keyword value like "normal" is present
+ if (!this.props.valueLabel) {
+ this.updateValue(value);
+ }
+
+ this.toggleInteractiveState(false);
+ }
+
+ /**
+ * Handler for "change" events from the range and number input fields. Calls the change
+ * handler provided with props and updates internal state with the current value.
+ *
+ * Number inputs in Firefox can't be trusted to filter out non-digit characters,
+ * therefore we must implement our own validation.
+ * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1398528
+ *
+ * @param {Event} e
+ * Change event.
+ */
+ onChange(e) {
+ // Regular expresion to check for floating point or integer numbers. Accept negative
+ // numbers only if the min value is negative. Otherwise, expect positive numbers.
+ // Whitespace and non-digit characters are invalid (aside from a single dot).
+ const regex =
+ this.props.min && this.props.min < 0
+ ? /^-?[0-9]+(.[0-9]+)?$/
+ : /^[0-9]+(.[0-9]+)?$/;
+ let string = e.target.value.trim();
+
+ if (e.target.validity.badInput) {
+ return;
+ }
+
+ // Prefix with zero if the string starts with a dot: .5 => 0.5
+ if (string.charAt(0) === "." && string.length > 1) {
+ string = "0" + string;
+ e.target.value = string;
+ }
+
+ // Accept empty strings to allow the input value to be completely erased while typing.
+ // A null value will be handled on blur. @see this.onBlur()
+ if (string === "") {
+ this.setState(prevState => {
+ return {
+ ...prevState,
+ value: null,
+ };
+ });
+
+ return;
+ }
+
+ if (!regex.test(string)) {
+ return;
+ }
+
+ const value = parseFloat(string);
+ this.updateValue(value);
+ }
+
+ onFocus(e) {
+ if (e.target.type === "number") {
+ e.target.select();
+ }
+
+ this.setState(prevState => {
+ return {
+ ...prevState,
+ interactive: true,
+ initialValue: this.props.value,
+ };
+ });
+ }
+
+ onUnitChange(e) {
+ this.props.onChange(
+ this.props.name,
+ this.props.value,
+ this.props.unit,
+ e.target.value
+ );
+ // Reset internal state value and wait for converted value from props.
+ this.setState(prevState => {
+ return {
+ ...prevState,
+ value: null,
+ };
+ });
+ }
+
+ onMouseDown() {
+ this.toggleInteractiveState(true);
+ }
+
+ onMouseUp() {
+ this.toggleInteractiveState(false);
+ }
+
+ /**
+ * Toggle the "interactive" state which causes render() to use `value` fom internal
+ * state instead of from props to prevent jittering during continous dragging of the
+ * range input thumb or incrementing from the number input.
+ *
+ * @param {Boolean} isInteractive
+ * Whether to mark the interactive state on or off.
+ */
+ toggleInteractiveState(isInteractive) {
+ this.setState(prevState => {
+ return {
+ ...prevState,
+ interactive: isInteractive,
+ };
+ });
+ }
+
+ /**
+ * Calls the given `onChange` callback with the current property name, value and unit
+ * if the value is valid according to the constraints of this component (min, max).
+ * Updates the internal state with the current value. This will be used to render the
+ * UI while the input is interactive and the user may be typing a value that's not yet
+ * valid.
+ *
+ * @see this.onBlur() for logic reconciling the internal state with props.
+ *
+ * @param {Number} value
+ * Numeric property value.
+ */
+ updateValue(value) {
+ if (this.isValueValid(value)) {
+ this.props.onChange(this.props.name, value, this.props.unit);
+ }
+
+ this.setState(prevState => {
+ return {
+ ...prevState,
+ value,
+ };
+ });
+ }
+
+ renderUnitSelect() {
+ if (!this.props.unitOptions.length) {
+ return null;
+ }
+
+ // Ensure the select element has the current unit type even if we don't recognize it.
+ // The unit conversion function will use a 1-to-1 scale for unrecognized units.
+ const options = this.props.unitOptions.includes(this.props.unit)
+ ? this.props.unitOptions
+ : this.props.unitOptions.concat([this.props.unit]);
+
+ return dom.select(
+ {
+ className: "font-value-select",
+ disabled: this.props.disabled,
+ onChange: this.onUnitChange,
+ value: this.props.unit,
+ },
+ options.map(unit => {
+ return dom.option(
+ {
+ key: unit,
+ value: unit,
+ },
+ unit
+ );
+ })
+ );
+ }
+
+ renderLabelContent() {
+ const { label, name, nameLabel } = this.props;
+
+ const labelEl = dom.span(
+ {
+ className: "font-control-label-text",
+ "aria-describedby": nameLabel ? `detail-${name}` : null,
+ },
+ label
+ );
+
+ // Show the `name` prop value as an additional label if the `nameLabel` prop is true.
+ const detailEl = nameLabel
+ ? dom.span(
+ {
+ className: "font-control-label-detail",
+ id: `detail-${name}`,
+ },
+ this.getPropLabel("name")
+ )
+ : null;
+
+ return createElement(Fragment, null, labelEl, detailEl);
+ }
+
+ renderValueLabel() {
+ if (!this.props.valueLabel) {
+ return null;
+ }
+
+ return dom.div({ className: "font-value-label" }, this.props.valueLabel);
+ }
+
+ render() {
+ // Guard against bad axis data.
+ if (this.props.min === this.props.max) {
+ return null;
+ }
+
+ const propsValue =
+ this.props.value !== null ? this.props.value : this.props.defaultValue;
+
+ const defaults = {
+ min: this.props.min,
+ max: this.props.max,
+ onBlur: this.onBlur,
+ onChange: this.onChange,
+ onFocus: this.onFocus,
+ step: this.props.step,
+ // While interacting with the range and number inputs, prevent updating value from
+ // outside props which is debounced and causes jitter on successive renders.
+ value: this.state.interactive ? this.state.value : propsValue,
+ };
+
+ const range = dom.input({
+ ...defaults,
+ onMouseDown: this.onMouseDown,
+ onMouseUp: this.onMouseUp,
+ className: "font-value-slider",
+ disabled: this.props.disabled,
+ name: this.props.name,
+ title: this.props.label,
+ type: "range",
+ });
+
+ const input = dom.input({
+ ...defaults,
+ // Remove lower limit from number input if it is allowed to underflow.
+ min: this.props.allowUnderflow ? null : this.props.min,
+ // Remove upper limit from number input if it is allowed to overflow.
+ max: this.props.allowOverflow ? null : this.props.max,
+ name: this.props.name,
+ className: "font-value-input",
+ disabled: this.props.disabled,
+ type: "number",
+ });
+
+ return dom.label(
+ {
+ className: `font-control ${this.props.className}`,
+ disabled: this.props.disabled,
+ },
+ dom.div(
+ {
+ className: "font-control-label",
+ title: this.props.label,
+ },
+ this.renderLabelContent()
+ ),
+ dom.div(
+ {
+ className: "font-control-input",
+ },
+ dom.div(
+ {
+ className: "font-value-slider-container",
+ "data-min": this.getPropLabel("min"),
+ "data-max": this.getPropLabel("max"),
+ },
+ range
+ ),
+ this.renderValueLabel(),
+ this.props.showInput && input,
+ this.props.showUnit && this.renderUnitSelect()
+ )
+ );
+ }
+}
+
+module.exports = FontPropertyValue;
diff --git a/devtools/client/inspector/fonts/components/FontSize.js b/devtools/client/inspector/fonts/components/FontSize.js
new file mode 100644
index 0000000000..dbb67a66d9
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontSize.js
@@ -0,0 +1,87 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const FontPropertyValue = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontPropertyValue.js")
+);
+
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/fonts/utils/l10n.js");
+const {
+ getUnitFromValue,
+ getStepForUnit,
+} = require("resource://devtools/client/inspector/fonts/utils/font-utils.js");
+
+class FontSize extends PureComponent {
+ static get propTypes() {
+ return {
+ disabled: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.string.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.historicMax = {};
+ }
+
+ render() {
+ const value = parseFloat(this.props.value);
+ const unit = getUnitFromValue(this.props.value);
+ let max;
+ switch (unit) {
+ case "em":
+ case "rem":
+ max = 4;
+ break;
+ case "vh":
+ case "vw":
+ case "vmin":
+ case "vmax":
+ max = 10;
+ break;
+ case "%":
+ max = 200;
+ break;
+ default:
+ max = 72;
+ break;
+ }
+
+ // Allow the upper bound to increase so it accomodates the out-of-bounds value.
+ max = Math.max(max, value);
+ // Ensure we store the max value ever reached for this unit type. This will be the
+ // max value of the input and slider. Without this memoization, the value and slider
+ // thumb get clamped at the upper bound while decrementing an out-of-bounds value.
+ this.historicMax[unit] = this.historicMax[unit]
+ ? Math.max(this.historicMax[unit], max)
+ : max;
+
+ return FontPropertyValue({
+ allowOverflow: true,
+ disabled: this.props.disabled,
+ label: getStr("fontinspector.fontSizeLabel"),
+ min: 0,
+ max: this.historicMax[unit],
+ name: "font-size",
+ onChange: this.props.onChange,
+ step: getStepForUnit(unit),
+ unit,
+ unitOptions: ["em", "rem", "%", "px", "vh", "vw"],
+ value,
+ });
+ }
+}
+
+module.exports = FontSize;
diff --git a/devtools/client/inspector/fonts/components/FontStyle.js b/devtools/client/inspector/fonts/components/FontStyle.js
new file mode 100644
index 0000000000..ccb411958a
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontStyle.js
@@ -0,0 +1,69 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/fonts/utils/l10n.js");
+
+class FontStyle extends PureComponent {
+ static get propTypes() {
+ return {
+ disabled: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.string.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.name = "font-style";
+ this.onToggle = this.onToggle.bind(this);
+ }
+
+ onToggle(e) {
+ this.props.onChange(
+ this.name,
+ e.target.checked ? "italic" : "normal",
+ null
+ );
+ }
+
+ render() {
+ return dom.label(
+ {
+ className: "font-control",
+ },
+ dom.span(
+ {
+ className: "font-control-label",
+ },
+ getStr("fontinspector.fontItalicLabel")
+ ),
+ dom.div(
+ {
+ className: "font-control-input",
+ },
+ dom.input({
+ checked:
+ this.props.value === "italic" || this.props.value === "oblique",
+ className: "devtools-checkbox-toggle",
+ disabled: this.props.disabled,
+ name: this.name,
+ onChange: this.onToggle,
+ type: "checkbox",
+ })
+ )
+ );
+ }
+}
+
+module.exports = FontStyle;
diff --git a/devtools/client/inspector/fonts/components/FontWeight.js b/devtools/client/inspector/fonts/components/FontWeight.js
new file mode 100644
index 0000000000..2c86f2f318
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontWeight.js
@@ -0,0 +1,45 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const FontPropertyValue = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontPropertyValue.js")
+);
+
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/fonts/utils/l10n.js");
+
+class FontWeight extends PureComponent {
+ static get propTypes() {
+ return {
+ disabled: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.string.isRequired,
+ };
+ }
+
+ render() {
+ return FontPropertyValue({
+ disabled: this.props.disabled,
+ label: getStr("fontinspector.fontWeightLabel"),
+ min: 100,
+ max: 900,
+ name: "font-weight",
+ onChange: this.props.onChange,
+ step: 100,
+ unit: null,
+ value: parseFloat(this.props.value),
+ });
+ }
+}
+
+module.exports = FontWeight;
diff --git a/devtools/client/inspector/fonts/components/FontsApp.js b/devtools/client/inspector/fonts/components/FontsApp.js
new file mode 100644
index 0000000000..66e820314a
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/FontsApp.js
@@ -0,0 +1,71 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+const FontEditor = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontEditor.js")
+);
+const FontOverview = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontOverview.js")
+);
+
+const Types = require("resource://devtools/client/inspector/fonts/types.js");
+
+class FontsApp extends PureComponent {
+ static get propTypes() {
+ return {
+ fontData: PropTypes.shape(Types.fontData).isRequired,
+ fontEditor: PropTypes.shape(Types.fontEditor).isRequired,
+ fontOptions: PropTypes.shape(Types.fontOptions).isRequired,
+ onInstanceChange: PropTypes.func.isRequired,
+ onPreviewTextChange: PropTypes.func.isRequired,
+ onPropertyChange: PropTypes.func.isRequired,
+ onToggleFontHighlight: PropTypes.func.isRequired,
+ };
+ }
+
+ render() {
+ const {
+ fontData,
+ fontEditor,
+ fontOptions,
+ onInstanceChange,
+ onPreviewTextChange,
+ onPropertyChange,
+ onToggleFontHighlight,
+ } = this.props;
+
+ return dom.div(
+ {
+ className: "theme-sidebar inspector-tabpanel",
+ id: "sidebar-panel-fontinspector",
+ },
+ FontEditor({
+ fontEditor,
+ onInstanceChange,
+ onPropertyChange,
+ onToggleFontHighlight,
+ }),
+ FontOverview({
+ fontData,
+ fontOptions,
+ onPreviewTextChange,
+ onToggleFontHighlight,
+ })
+ );
+ }
+}
+
+module.exports = connect(state => state)(FontsApp);
diff --git a/devtools/client/inspector/fonts/components/LetterSpacing.js b/devtools/client/inspector/fonts/components/LetterSpacing.js
new file mode 100644
index 0000000000..42d6ddfa61
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/LetterSpacing.js
@@ -0,0 +1,105 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const FontPropertyValue = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontPropertyValue.js")
+);
+
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/fonts/utils/l10n.js");
+const {
+ getUnitFromValue,
+ getStepForUnit,
+} = require("resource://devtools/client/inspector/fonts/utils/font-utils.js");
+
+class LetterSpacing extends PureComponent {
+ static get propTypes() {
+ return {
+ disabled: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.string.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ // Local state for min/max bounds indexed by unit to allow user input that
+ // goes out-of-bounds while still providing a meaningful default range. The indexing
+ // by unit is needed to account for unit conversion (ex: em to px) where the operation
+ // may result in out-of-bounds values. Avoiding React's state and setState() because
+ // `value` is a prop coming from the Redux store while min/max are local. Reconciling
+ // value/unit changes is needlessly complicated and adds unnecessary re-renders.
+ this.historicMin = {};
+ this.historicMax = {};
+ }
+
+ getDefaultMinMax(unit) {
+ let min;
+ let max;
+ switch (unit) {
+ case "px":
+ min = -10;
+ max = 10;
+ break;
+ default:
+ min = -0.2;
+ max = 0.6;
+ break;
+ }
+
+ return { min, max };
+ }
+
+ render() {
+ // For a unitless or a NaN value, default unit to "em".
+ const unit = getUnitFromValue(this.props.value) || "em";
+ // When the initial value of "letter-spacing" is "normal", the parsed value
+ // is not a number (NaN). Guard by setting the default value to 0.
+ const isKeywordValue = this.props.value === "normal";
+ const value = isKeywordValue ? 0 : parseFloat(this.props.value);
+
+ let { min, max } = this.getDefaultMinMax(unit);
+ min = Math.min(min, value);
+ max = Math.max(max, value);
+ // Allow lower and upper bounds to move to accomodate the incoming value.
+ this.historicMin[unit] = this.historicMin[unit]
+ ? Math.min(this.historicMin[unit], min)
+ : min;
+ this.historicMax[unit] = this.historicMax[unit]
+ ? Math.max(this.historicMax[unit], max)
+ : max;
+
+ return FontPropertyValue({
+ allowOverflow: true,
+ allowUnderflow: true,
+ disabled: this.props.disabled,
+ label: getStr("fontinspector.letterSpacingLabel"),
+ min: this.historicMin[unit],
+ max: this.historicMax[unit],
+ name: "letter-spacing",
+ onChange: this.props.onChange,
+ // Increase the increment granularity because letter spacing is very sensitive.
+ step: getStepForUnit(unit) / 100,
+ // Show the value input and unit only when the value is not a keyword.
+ showInput: !isKeywordValue,
+ showUnit: !isKeywordValue,
+ unit,
+ unitOptions: ["em", "rem", "px"],
+ value,
+ // Show the value as a read-only label if it's a keyword.
+ valueLabel: isKeywordValue ? this.props.value : null,
+ });
+ }
+}
+
+module.exports = LetterSpacing;
diff --git a/devtools/client/inspector/fonts/components/LineHeight.js b/devtools/client/inspector/fonts/components/LineHeight.js
new file mode 100644
index 0000000000..34949dc7e8
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/LineHeight.js
@@ -0,0 +1,101 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const FontPropertyValue = createFactory(
+ require("resource://devtools/client/inspector/fonts/components/FontPropertyValue.js")
+);
+
+const {
+ getStr,
+} = require("resource://devtools/client/inspector/fonts/utils/l10n.js");
+const {
+ getUnitFromValue,
+ getStepForUnit,
+} = require("resource://devtools/client/inspector/fonts/utils/font-utils.js");
+
+class LineHeight extends PureComponent {
+ static get propTypes() {
+ return {
+ disabled: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.string.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.historicMax = {};
+ }
+
+ render() {
+ // When the initial value of "line-height" is "normal", the parsed value
+ // is not a number (NaN). Guard by setting the default value to 1.2.
+ // This will be the starting point for changing the value by dragging the slider.
+ // @see https://searchfox.org/mozilla-central/rev/1133b6716d9a8131c09754f3f29288484896b8b6/layout/generic/ReflowInput.cpp#2786
+ const isKeywordValue = this.props.value === "normal";
+ const value = isKeywordValue ? 1.2 : parseFloat(this.props.value);
+
+ // When values for line-height are be unitless, getUnitFromValue() returns null.
+ // In that case, set the unit to an empty string for special treatment in conversion.
+ const unit = getUnitFromValue(this.props.value) || "";
+ let max;
+ switch (unit) {
+ case "":
+ case "em":
+ case "rem":
+ max = 2;
+ break;
+ case "vh":
+ case "vw":
+ case "vmin":
+ case "vmax":
+ max = 15;
+ break;
+ case "%":
+ max = 200;
+ break;
+ default:
+ max = 108;
+ break;
+ }
+
+ // Allow the upper bound to increase so it accomodates the out-of-bounds value.
+ max = Math.max(max, value);
+ // Ensure we store the max value ever reached for this unit type. This will be the
+ // max value of the input and slider. Without this memoization, the value and slider
+ // thumb get clamped at the upper bound while decrementing an out-of-bounds value.
+ this.historicMax[unit] = this.historicMax[unit]
+ ? Math.max(this.historicMax[unit], max)
+ : max;
+
+ return FontPropertyValue({
+ allowOverflow: true,
+ disabled: this.props.disabled,
+ label: getStr("fontinspector.lineHeightLabelCapitalized"),
+ min: 0,
+ max: this.historicMax[unit],
+ name: "line-height",
+ onChange: this.props.onChange,
+ step: getStepForUnit(unit),
+ // Show the value input and unit only when the value is not a keyword.
+ showInput: !isKeywordValue,
+ showUnit: !isKeywordValue,
+ unit,
+ unitOptions: ["", "em", "%", "px"],
+ value,
+ // Show the value as a read-only label if it's a keyword.
+ valueLabel: isKeywordValue ? this.props.value : null,
+ });
+ }
+}
+
+module.exports = LineHeight;
diff --git a/devtools/client/inspector/fonts/components/moz.build b/devtools/client/inspector/fonts/components/moz.build
new file mode 100644
index 0000000000..8838777f65
--- /dev/null
+++ b/devtools/client/inspector/fonts/components/moz.build
@@ -0,0 +1,24 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "Font.js",
+ "FontAxis.js",
+ "FontEditor.js",
+ "FontList.js",
+ "FontName.js",
+ "FontOrigin.js",
+ "FontOverview.js",
+ "FontPreview.js",
+ "FontPreviewInput.js",
+ "FontPropertyValue.js",
+ "FontsApp.js",
+ "FontSize.js",
+ "FontStyle.js",
+ "FontWeight.js",
+ "LetterSpacing.js",
+ "LineHeight.js",
+)
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;
diff --git a/devtools/client/inspector/fonts/moz.build b/devtools/client/inspector/fonts/moz.build
new file mode 100644
index 0000000000..89106c469c
--- /dev/null
+++ b/devtools/client/inspector/fonts/moz.build
@@ -0,0 +1,19 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+ "actions",
+ "components",
+ "reducers",
+ "utils",
+]
+
+DevToolsModules(
+ "fonts.js",
+ "types.js",
+)
+
+BROWSER_CHROME_MANIFESTS += ["test/browser.toml"]
diff --git a/devtools/client/inspector/fonts/reducers/font-editor.js b/devtools/client/inspector/fonts/reducers/font-editor.js
new file mode 100644
index 0000000000..b40fff4ba1
--- /dev/null
+++ b/devtools/client/inspector/fonts/reducers/font-editor.js
@@ -0,0 +1,157 @@
+/* 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 {
+ getStr,
+} = require("resource://devtools/client/inspector/fonts/utils/l10n.js");
+const {
+ parseFontVariationAxes,
+} = require("resource://devtools/client/inspector/fonts/utils/font-utils.js");
+
+const {
+ APPLY_FONT_VARIATION_INSTANCE,
+ RESET_EDITOR,
+ SET_FONT_EDITOR_DISABLED,
+ UPDATE_AXIS_VALUE,
+ UPDATE_EDITOR_STATE,
+ UPDATE_PROPERTY_VALUE,
+ UPDATE_WARNING_MESSAGE,
+} = require("resource://devtools/client/inspector/fonts/actions/index.js");
+
+const CUSTOM_INSTANCE_NAME = getStr("fontinspector.customInstanceName");
+
+const INITIAL_STATE = {
+ // Variable font axes.
+ axes: {},
+ // Copy of the most recent axes values. Used to revert from a named instance.
+ customInstanceValues: [],
+ // When true, prevent users from interacting with inputs in the font editor.
+ disabled: false,
+ // Fonts used on the selected element.
+ fonts: [],
+ // Current selected font variation instance.
+ instance: {
+ name: CUSTOM_INSTANCE_NAME,
+ values: [],
+ },
+ // CSS font properties defined on the selected rule.
+ properties: {},
+ // Unique identifier for the selected element.
+ id: "",
+ // Warning message with the reason why the font editor cannot be shown.
+ warning: getStr("fontinspector.noFontsUsedOnCurrentElement"),
+};
+
+const reducers = {
+ // Update font editor with the axes and values defined by a font variation instance.
+ [APPLY_FONT_VARIATION_INSTANCE](state, { name, values }) {
+ const newState = { ...state };
+ newState.instance.name = name;
+ newState.instance.values = values;
+
+ if (Array.isArray(values) && values.length) {
+ newState.axes = values.reduce((acc, value) => {
+ acc[value.axis] = value.value;
+ return acc;
+ }, {});
+ }
+
+ return newState;
+ },
+
+ [RESET_EDITOR](state) {
+ return { ...INITIAL_STATE };
+ },
+
+ [UPDATE_AXIS_VALUE](state, { axis, value }) {
+ const newState = { ...state };
+ newState.axes[axis] = value;
+
+ // Cache the latest axes and their values to restore them when switching back from
+ // a named font variation instance to the custom font variation instance.
+ newState.customInstanceValues = Object.keys(state.axes).map(axisName => {
+ return { axis: [axisName], value: state.axes[axisName] };
+ });
+
+ // As soon as an axis value is manually updated, mark the custom font variation
+ // instance as selected.
+ newState.instance.name = CUSTOM_INSTANCE_NAME;
+
+ return newState;
+ },
+
+ [SET_FONT_EDITOR_DISABLED](state, { disabled }) {
+ return { ...state, disabled };
+ },
+
+ [UPDATE_EDITOR_STATE](state, { fonts, properties, id }) {
+ const axes = parseFontVariationAxes(properties["font-variation-settings"]);
+
+ // If not defined in font-variation-settings, setup "wght" axis with the value of
+ // "font-weight" if it is numeric and not a keyword.
+ const weight = properties["font-weight"];
+ if (
+ axes.wght === undefined &&
+ parseFloat(weight).toString() === weight.toString()
+ ) {
+ axes.wght = parseFloat(weight);
+ }
+
+ // If not defined in font-variation-settings, setup "wdth" axis with the percentage
+ // number from the value of "font-stretch" if it is not a keyword.
+ const stretch = properties["font-stretch"];
+ // Match the number part from values like: 10%, 10.55%, 0.2%
+ // If there's a match, the number is the second item in the match array.
+ const match = stretch.trim().match(/^(\d+(.\d+)?)%$/);
+ if (axes.wdth === undefined && match && match[1]) {
+ axes.wdth = parseFloat(match[1]);
+ }
+
+ // If not defined in font-variation-settings, setup "slnt" axis with the negative
+ // of the "font-style: oblique" angle, if any.
+ const style = properties["font-style"];
+ const obliqueMatch = style.trim().match(/^oblique(?:\s*(\d+(.\d+)?)deg)?$/);
+ if (axes.slnt === undefined && obliqueMatch) {
+ if (obliqueMatch[1]) {
+ // Negate the angle because CSS and OpenType measure in opposite directions.
+ axes.slnt = -parseFloat(obliqueMatch[1]);
+ } else {
+ // Lack of an <angle> for "font-style: oblique" represents "14deg".
+ axes.slnt = -14;
+ }
+ }
+
+ // If not defined in font-variation-settings, setup "ital" axis with 0 for
+ // "font-style: normal" or 1 for "font-style: italic".
+ if (axes.ital === undefined) {
+ if (style === "normal") {
+ axes.ital = 0;
+ } else if (style === "italic") {
+ axes.ital = 1;
+ }
+ }
+
+ return { ...state, axes, fonts, properties, id };
+ },
+
+ [UPDATE_PROPERTY_VALUE](state, { property, value }) {
+ const newState = { ...state };
+ newState.properties[property] = value;
+ return newState;
+ },
+
+ [UPDATE_WARNING_MESSAGE](state, { warning }) {
+ return { ...state, warning };
+ },
+};
+
+module.exports = function (state = INITIAL_STATE, action) {
+ const reducer = reducers[action.type];
+ if (!reducer) {
+ return state;
+ }
+ return reducer(state, action);
+};
diff --git a/devtools/client/inspector/fonts/reducers/font-options.js b/devtools/client/inspector/fonts/reducers/font-options.js
new file mode 100644
index 0000000000..9df8625e56
--- /dev/null
+++ b/devtools/client/inspector/fonts/reducers/font-options.js
@@ -0,0 +1,27 @@
+/* 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 {
+ UPDATE_PREVIEW_TEXT,
+} = require("resource://devtools/client/inspector/fonts/actions/index.js");
+
+const INITIAL_FONT_OPTIONS = {
+ previewText: "",
+};
+
+const reducers = {
+ [UPDATE_PREVIEW_TEXT](fontOptions, { previewText }) {
+ return Object.assign({}, fontOptions, { previewText });
+ },
+};
+
+module.exports = function (fontOptions = INITIAL_FONT_OPTIONS, action) {
+ const reducer = reducers[action.type];
+ if (!reducer) {
+ return fontOptions;
+ }
+ return reducer(fontOptions, action);
+};
diff --git a/devtools/client/inspector/fonts/reducers/fonts.js b/devtools/client/inspector/fonts/reducers/fonts.js
new file mode 100644
index 0000000000..92a6ef87c1
--- /dev/null
+++ b/devtools/client/inspector/fonts/reducers/fonts.js
@@ -0,0 +1,28 @@
+/* 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 {
+ UPDATE_FONTS,
+} = require("resource://devtools/client/inspector/fonts/actions/index.js");
+
+const INITIAL_FONT_DATA = {
+ // All fonts on the current page.
+ allFonts: [],
+};
+
+const reducers = {
+ [UPDATE_FONTS](_, { allFonts }) {
+ return { allFonts };
+ },
+};
+
+module.exports = function (fontData = INITIAL_FONT_DATA, action) {
+ const reducer = reducers[action.type];
+ if (!reducer) {
+ return fontData;
+ }
+ return reducer(fontData, action);
+};
diff --git a/devtools/client/inspector/fonts/reducers/moz.build b/devtools/client/inspector/fonts/reducers/moz.build
new file mode 100644
index 0000000000..13d1c7cf34
--- /dev/null
+++ b/devtools/client/inspector/fonts/reducers/moz.build
@@ -0,0 +1,11 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "font-editor.js",
+ "font-options.js",
+ "fonts.js",
+)
diff --git a/devtools/client/inspector/fonts/test/OstrichLicense.txt b/devtools/client/inspector/fonts/test/OstrichLicense.txt
new file mode 100644
index 0000000000..14c043d601
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/OstrichLicense.txt
@@ -0,0 +1,41 @@
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
+
+5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file
diff --git a/devtools/client/inspector/fonts/test/browser.toml b/devtools/client/inspector/fonts/test/browser.toml
new file mode 100644
index 0000000000..380380376a
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser.toml
@@ -0,0 +1,55 @@
+[DEFAULT]
+tags = "devtools"
+subsuite = "devtools"
+support-files = [
+ "doc_browser_fontinspector.html",
+ "doc_browser_fontinspector_iframe.html",
+ "test_iframe.html",
+ "ostrich-black.ttf",
+ "ostrich-regular.ttf",
+ "head.js",
+ "!/devtools/client/inspector/test/head.js",
+ "!/devtools/client/inspector/test/shared-head.js",
+ "!/devtools/client/shared/test/shared-head.js",
+ "!/devtools/client/shared/test/telemetry-test-helpers.js",
+ "!/devtools/client/shared/test/highlighter-test-actor.js",
+]
+
+["browser_fontinspector.js"]
+
+["browser_fontinspector_all-fonts.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_fontinspector_copy-URL.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_fontinspector_edit-previews.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_fontinspector_editor-font-size-conversion.js"]
+
+["browser_fontinspector_editor-keywords.js"]
+
+["browser_fontinspector_editor-letter-spacing-conversion.js"]
+
+["browser_fontinspector_editor-values.js"]
+
+["browser_fontinspector_expand-css-code.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
+
+["browser_fontinspector_font-type-telemetry.js"]
+
+["browser_fontinspector_input-element-used-font.js"]
+
+["browser_fontinspector_no-fonts.js"]
+
+["browser_fontinspector_reveal-in-page.js"]
+skip-if = [
+ "http3", # Bug 1829298
+ "http2",
+]
+
+["browser_fontinspector_text-node.js"]
+
+["browser_fontinspector_theme-change.js"]
+fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector.js b/devtools/client/inspector/fonts/test/browser_fontinspector.js
new file mode 100644
index 0000000000..98c4fc6b9f
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+requestLongerTimeout(2);
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+
+add_task(async function () {
+ const { inspector, view } = await openFontInspectorForURL(TEST_URI);
+ ok(!!view, "Font inspector document is alive.");
+
+ const viewDoc = view.document;
+
+ await testBodyFonts(inspector, viewDoc);
+ await testDivFonts(inspector, viewDoc);
+});
+
+async function testBodyFonts(inspector, viewDoc) {
+ const FONTS = [
+ {
+ familyName: "bar",
+ name: ["Ostrich Sans Medium", "Ostrich Sans Black"],
+ },
+ {
+ familyName: "barnormal",
+ name: "Ostrich Sans Medium",
+ },
+ {
+ // On Linux, Arial does not exist. Liberation Sans is used instead.
+ familyName: ["Arial", "Liberation Sans"],
+ name: ["Arial", "Liberation Sans"],
+ },
+ ];
+
+ await selectNode("body", inspector);
+
+ const groups = getUsedFontGroupsEls(viewDoc);
+ is(groups.length, 3, "Found 3 font families used on BODY");
+
+ for (let i = 0; i < FONTS.length; i++) {
+ const groupEL = groups[i];
+ const font = FONTS[i];
+
+ const familyName = getFamilyName(groupEL);
+ ok(
+ font.familyName.includes(familyName),
+ `Font families used on BODY include: ${familyName}`
+ );
+
+ const fontName = getName(groupEL);
+ ok(font.name.includes(fontName), `Fonts used on BODY include: ${fontName}`);
+ }
+}
+
+async function testDivFonts(inspector, viewDoc) {
+ const FONTS = [
+ {
+ selector: "div",
+ familyName: "bar",
+ name: "Ostrich Sans Medium",
+ },
+ {
+ selector: ".normal-text",
+ familyName: "barnormal",
+ name: "Ostrich Sans Medium",
+ },
+ {
+ selector: ".bold-text",
+ familyName: "bar",
+ name: "Ostrich Sans Black",
+ },
+ {
+ selector: ".black-text",
+ familyName: "bar",
+ name: "Ostrich Sans Black",
+ },
+ ];
+
+ for (let i = 0; i < FONTS.length; i++) {
+ await selectNode(FONTS[i].selector, inspector);
+ const groups = getUsedFontGroupsEls(viewDoc);
+ const groupEl = groups[0];
+ const font = FONTS[i];
+
+ is(groups.length, 1, `Found 1 font on ${FONTS[i].selector}`);
+ is(getName(groupEl), font.name, "The DIV font has the right name");
+ is(
+ getFamilyName(groupEl),
+ font.familyName,
+ `font has the right family name`
+ );
+ }
+}
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_all-fonts.js b/devtools/client/inspector/fonts/test/browser_fontinspector_all-fonts.js
new file mode 100644
index 0000000000..7f2acfaebb
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_all-fonts.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Check that the font editor has a section for "All fonts" which shows all fonts
+// used on the page.
+
+const TEST_URI = URL_ROOT_SSL + "doc_browser_fontinspector.html";
+
+add_task(async function () {
+ const { view } = await openFontInspectorForURL(TEST_URI);
+ const viewDoc = view.document;
+
+ const allFontsAccordion = getFontsAccordion(viewDoc);
+ ok(allFontsAccordion, "There's an accordion in the panel");
+ is(
+ allFontsAccordion.textContent,
+ "All Fonts on Page",
+ "It has the right title"
+ );
+
+ await expandAccordion(allFontsAccordion);
+ const allFontsEls = getAllFontsEls(viewDoc);
+
+ const FONTS = [
+ {
+ familyName: ["bar"],
+ name: ["Ostrich Sans Medium"],
+ remote: true,
+ url: URL_ROOT_SSL + "ostrich-regular.ttf",
+ },
+ {
+ familyName: ["bar"],
+ name: ["Ostrich Sans Black"],
+ remote: true,
+ url: URL_ROOT_SSL + "ostrich-black.ttf",
+ },
+ {
+ familyName: ["bar"],
+ name: ["Ostrich Sans Black"],
+ remote: true,
+ url: URL_ROOT_SSL + "ostrich-black.ttf",
+ },
+ {
+ familyName: ["barnormal"],
+ name: ["Ostrich Sans Medium"],
+ remote: true,
+ url: URL_ROOT_SSL + "ostrich-regular.ttf",
+ },
+ {
+ // On Linux, Arial does not exist. Liberation Sans is used instead.
+ familyName: ["Arial", "Liberation Sans"],
+ name: ["Arial", "Liberation Sans"],
+ remote: false,
+ url: "system",
+ },
+ {
+ // On Linux, Times New Roman does not exist. Liberation Serif is used instead.
+ familyName: ["Times New Roman", "Liberation Serif"],
+ name: ["Times New Roman", "Liberation Serif"],
+ remote: false,
+ url: "system",
+ },
+ ];
+
+ is(allFontsEls.length, FONTS.length, "All fonts used are listed");
+
+ for (let i = 0; i < FONTS.length; i++) {
+ const li = allFontsEls[i];
+ const font = FONTS[i];
+
+ ok(font.name.includes(getName(li)), "The DIV font has the right name");
+ info(getName(li));
+ ok(
+ font.familyName.includes(getFamilyName(li)),
+ `font has the right family name`
+ );
+ info(getFamilyName(li));
+ is(isRemote(li), font.remote, `font remote value correct`);
+ is(getURL(li), font.url, `font url correct`);
+ }
+});
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_copy-URL.js b/devtools/client/inspector/fonts/test/browser_fontinspector_copy-URL.js
new file mode 100644
index 0000000000..e871fb42f3
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_copy-URL.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that an icon appears next to web font URLs, and that clicking it copies the URL
+// to the clipboard thanks to it.
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+
+add_task(async function () {
+ const { view, inspector } = await openFontInspectorForURL(TEST_URI);
+ const viewDoc = view.document;
+ await selectNode("div", inspector);
+ await expandFontsAccordion(viewDoc);
+ const allFontsEls = getAllFontsEls(viewDoc);
+ const fontEl = allFontsEls[0];
+
+ const linkEl = fontEl.querySelector(".font-origin");
+ const iconEl = linkEl.querySelector(".copy-icon");
+
+ ok(iconEl, "The icon is displayed");
+ is(iconEl.getAttribute("title"), "Copy URL", "This is the right icon");
+
+ info("Clicking the button and waiting for the clipboard to receive the URL");
+ await waitForClipboardPromise(() => iconEl.click(), linkEl.textContent);
+});
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_edit-previews.js b/devtools/client/inspector/fonts/test/browser_fontinspector_edit-previews.js
new file mode 100644
index 0000000000..b29be4ca3f
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_edit-previews.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that previews change when the preview text changes. It doesn't check the
+// exact preview images because they are drawn on a canvas causing them to vary
+// between systems, platforms and software versions.
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+
+add_task(async function () {
+ const { view, inspector } = await openFontInspectorForURL(TEST_URI);
+ const viewDoc = view.document;
+ await selectNode("div", inspector);
+ await expandFontsAccordion(viewDoc);
+
+ const previews = viewDoc.querySelectorAll("#font-container .font-preview");
+ const initialPreviews = [...previews].map(p => p.src);
+
+ info("Typing 'Abc' to check that the reference previews are correct.");
+ await updatePreviewText(view, "Abc");
+ checkPreviewImages(viewDoc, initialPreviews, true);
+
+ info("Typing something else to the preview box.");
+ await updatePreviewText(view, "The quick brown");
+ checkPreviewImages(viewDoc, initialPreviews, false);
+
+ info("Blanking the input to restore default previews.");
+ await updatePreviewText(view, "");
+ checkPreviewImages(viewDoc, initialPreviews, true);
+});
+
+/**
+ * Compares the previous preview image URIs to the current URIs.
+ *
+ * @param {Document} viewDoc
+ * The FontInspector document.
+ * @param {Array[String]} originalURIs
+ * An array of URIs to compare with the current URIs.
+ * @param {Boolean} assertIdentical
+ * If true, this method asserts that the previous and current URIs are
+ * identical. If false, this method asserts that the previous and current
+ * URI's are different.
+ */
+function checkPreviewImages(viewDoc, originalURIs, assertIdentical) {
+ const previews = viewDoc.querySelectorAll("#font-container .font-preview");
+ const newURIs = [...previews].map(p => p.src);
+
+ is(
+ newURIs.length,
+ originalURIs.length,
+ "The number of previews has not changed."
+ );
+
+ for (let i = 0; i < newURIs.length; ++i) {
+ if (assertIdentical) {
+ is(
+ newURIs[i],
+ originalURIs[i],
+ `The preview image at index ${i} has stayed the same.`
+ );
+ } else {
+ isnot(
+ newURIs[i],
+ originalURIs[i],
+ `The preview image at index ${i} has changed.`
+ );
+ }
+ }
+}
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_editor-font-size-conversion.js b/devtools/client/inspector/fonts/test/browser_fontinspector_editor-font-size-conversion.js
new file mode 100644
index 0000000000..f12769ff01
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_editor-font-size-conversion.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Unit test for math behind conversion of units for font-size. A reference element is
+// needed for converting to and from relative units (rem, em, %). A controlled viewport
+// is needed (iframe) for converting to and from viewport units (vh, vw, vmax, vmin).
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector_iframe.html";
+
+add_task(async function () {
+ const { inspector, view } = await openFontInspectorForURL(TEST_URI);
+ const viewDoc = view.document;
+ const property = "font-size";
+ const selector = ".viewport-size";
+ const UNITS = {
+ px: 50,
+ vw: 10,
+ vh: 20,
+ vmin: 20,
+ vmax: 10,
+ em: 1.389,
+ rem: 3.125,
+ "%": 138.889,
+ pt: 37.5,
+ pc: 3.125,
+ mm: 13.229,
+ cm: 1.323,
+ in: 0.521,
+ };
+
+ await selectNodeInFrames(["#frame", selector], inspector);
+
+ info("Check that font editor shows font-size value in original units");
+ const fontSize = getPropertyValue(viewDoc, property);
+ is(fontSize.unit, "vw", "Original unit for font size is vw");
+ is(fontSize.value + fontSize.unit, "10vw", "Original font size is 10vw");
+
+ // Starting value and unit for conversion.
+ let prevValue = fontSize.value;
+ let prevUnit = fontSize.unit;
+
+ for (const unit in UNITS) {
+ const value = UNITS[unit];
+
+ info(`Convert font-size from ${prevValue}${prevUnit} to ${unit}`);
+ const convertedValue = await view.convertUnits(
+ property,
+ prevValue,
+ prevUnit,
+ unit
+ );
+ is(
+ parseFloat(convertedValue),
+ parseFloat(value),
+ `Converting to ${unit} returns transformed value.`
+ );
+
+ // Store current unit and value to use in conversion on the next iteration.
+ prevUnit = unit;
+ prevValue = value;
+ }
+
+ info(`Check that conversion from fake unit returns 1-to-1 mapping.`);
+ const valueFromFakeUnit = await view.convertUnits(property, 1, "fake", "px");
+ is(valueFromFakeUnit, 1, `Converting from fake unit returns same value.`);
+
+ info(`Check that conversion to fake unit returns 1-to-1 mapping`);
+ const valueToFakeUnit = await view.convertUnits(property, 1, "px", "fake");
+ is(valueToFakeUnit, 1, `Converting to fake unit returns same value.`);
+
+ info(`Check that conversion between fake units returns 1-to-1 mapping.`);
+ const valueBetweenFakeUnit = await view.convertUnits(
+ property,
+ 1,
+ "bogus",
+ "fake"
+ );
+ is(
+ valueBetweenFakeUnit,
+ 1,
+ `Converting between fake units returns same value.`
+ );
+});
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_editor-keywords.js b/devtools/client/inspector/fonts/test/browser_fontinspector_editor-keywords.js
new file mode 100644
index 0000000000..328a9c9bcd
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_editor-keywords.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* global getPropertyValue */
+
+"use strict";
+
+// Test that keyword values for font properties don't show up in the font editor,
+// but their computed style values show up instead.
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+
+add_task(async function () {
+ const { inspector, view } = await openFontInspectorForURL(TEST_URI);
+ const viewDoc = view.document;
+
+ await testKeywordValues(inspector, viewDoc);
+});
+
+async function testKeywordValues(inspector, viewDoc) {
+ await selectNode(".bold-text", inspector);
+
+ info(
+ "Check font-weight shows its computed style instead of the bold keyword value."
+ );
+ const fontWeight = getPropertyValue(viewDoc, "font-weight");
+ isnot(fontWeight.value, "bold", "Font weight is not shown as keyword");
+ is(
+ parseInt(fontWeight.value, 10),
+ 700,
+ "Font weight is shown as computed style"
+ );
+
+ info(
+ "Check font-size shows its computed style instead of the inherit keyword value."
+ );
+ const fontSize = getPropertyValue(viewDoc, "font-size");
+ isnot(fontSize.unit, "inherit", "Font size unit is not shown as keyword");
+ is(fontSize.unit, "px", "Font size unit is shown as computed style");
+ is(
+ fontSize.value + fontSize.unit,
+ "36px",
+ "Font size is read as computed style"
+ );
+}
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_editor-letter-spacing-conversion.js b/devtools/client/inspector/fonts/test/browser_fontinspector_editor-letter-spacing-conversion.js
new file mode 100644
index 0000000000..ff680717a9
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_editor-letter-spacing-conversion.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* global getPropertyValue */
+
+"use strict";
+
+// Unit test for math behind conversion of units for letter-spacing.
+
+const TEST_URI = `
+ <style type='text/css'>
+ body {
+ /* Set root font-size to equivalent of 32px (2*16px) */
+ font-size: 200%;
+ }
+ div {
+ letter-spacing: 1em;
+ }
+ </style>
+ <div>LETTER SPACING</div>
+`;
+
+add_task(async function () {
+ const URI = "data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI);
+ const { inspector, view } = await openFontInspectorForURL(URI);
+ const viewDoc = view.document;
+ const property = "letter-spacing";
+ const UNITS = {
+ px: 32,
+ rem: 2,
+ em: 1,
+ };
+
+ await selectNode("div", inspector);
+
+ info("Check that font editor shows letter-spacing value in original units");
+ const letterSpacing = getPropertyValue(viewDoc, property);
+ is(
+ letterSpacing.value + letterSpacing.unit,
+ "1em",
+ "Original letter spacing is 1em"
+ );
+
+ // Starting value and unit for conversion.
+ let prevValue = letterSpacing.value;
+ let prevUnit = letterSpacing.unit;
+
+ for (const unit in UNITS) {
+ const value = UNITS[unit];
+
+ info(`Convert letter-spacing from ${prevValue}${prevUnit} to ${unit}`);
+ const convertedValue = await view.convertUnits(
+ property,
+ prevValue,
+ prevUnit,
+ unit
+ );
+ is(
+ convertedValue,
+ value,
+ `Converting to ${unit} returns transformed value.`
+ );
+
+ // Store current unit and value to use in conversion on the next iteration.
+ prevUnit = unit;
+ prevValue = value;
+ }
+
+ info(`Check that conversion to fake unit returns 1-to-1 mapping`);
+ const valueToFakeUnit = await view.convertUnits(property, 1, "px", "fake");
+ is(valueToFakeUnit, 1, `Converting to fake unit returns same value.`);
+});
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_editor-values.js b/devtools/client/inspector/fonts/test/browser_fontinspector_editor-values.js
new file mode 100644
index 0000000000..dd9879eff4
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_editor-values.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+
+add_task(async function () {
+ const { inspector, view } = await openFontInspectorForURL(TEST_URI);
+ const viewDoc = view.document;
+
+ await testDiv(inspector, viewDoc);
+ await testNestedSpan(inspector, viewDoc);
+});
+
+async function testDiv(inspector, viewDoc) {
+ await selectNode("DIV", inspector);
+ const { value, unit } = getPropertyValue(viewDoc, "font-size");
+
+ is(value + unit, "1em", "DIV should be have font-size of 1em");
+}
+
+async function testNestedSpan(inspector, viewDoc) {
+ await selectNode(".nested-span", inspector);
+ const { value, unit } = getPropertyValue(viewDoc, "font-size");
+
+ isnot(
+ value + unit,
+ "1em",
+ "Nested span should not reflect parent's font size."
+ );
+ is(
+ value + unit,
+ "36px",
+ "Nested span should have computed font-size of 36px"
+ );
+}
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_expand-css-code.js b/devtools/client/inspector/fonts/test/browser_fontinspector_expand-css-code.js
new file mode 100644
index 0000000000..66aedf93e7
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_expand-css-code.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the font-face css rule code is collapsed by default, and can be expanded.
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+
+add_task(async function () {
+ const { view, inspector } = await openFontInspectorForURL(TEST_URI);
+ const viewDoc = view.document;
+ await selectNode("div", inspector);
+
+ await expandFontsAccordion(viewDoc);
+ info("Checking that the css font-face rule is collapsed by default");
+ const fontEl = getAllFontsEls(viewDoc)[0];
+ const codeEl = fontEl.querySelector(".font-css-code");
+ is(codeEl.textContent, "@font-face {}", "The font-face rule is collapsed");
+
+ info("Expanding the rule by clicking on the expander icon");
+ const onExpanded = BrowserTestUtils.waitForCondition(() => {
+ return (
+ codeEl.textContent ===
+ `@font-face { font-family: bar; src: url("bad/font/name.ttf"), url("ostrich-regular.ttf") format("truetype"); }`
+ );
+ }, "Waiting for the font-face rule 1");
+
+ const expander = fontEl.querySelector(".font-css-code .theme-twisty");
+ expander.click();
+ await onExpanded;
+
+ ok(true, "Font-face rule is now expanded");
+});
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_font-type-telemetry.js b/devtools/client/inspector/fonts/test/browser_fontinspector_font-type-telemetry.js
new file mode 100644
index 0000000000..3c3e402437
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_font-type-telemetry.js
@@ -0,0 +1,20 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that telemetry works for tracking the font type shown in the Font Editor.
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+
+add_task(async function () {
+ const { inspector } = await openFontInspectorForURL(TEST_URI);
+ startTelemetry();
+ await selectNode(".normal-text", inspector);
+ await selectNode(".bold-text", inspector);
+ checkTelemetry(
+ "DEVTOOLS_FONTEDITOR_FONT_TYPE_DISPLAYED",
+ "",
+ null,
+ "hasentries"
+ );
+});
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_input-element-used-font.js b/devtools/client/inspector/fonts/test/browser_fontinspector_input-element-used-font.js
new file mode 100644
index 0000000000..8eacc3d632
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_input-element-used-font.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+
+// Verify that a styled input field element is showing proper font information
+// in its font tab.
+// Non-regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1435469
+add_task(async function () {
+ const { inspector, view } = await openFontInspectorForURL(TEST_URI);
+ const viewDoc = view.document;
+
+ await selectNode(".input-field", inspector);
+
+ const fontEls = getUsedFontsEls(viewDoc);
+ Assert.equal(fontEls.length, 1, `Used fonts found for styled input element`);
+ Assert.equal(
+ fontEls[0].textContent,
+ "Ostrich Sans Medium",
+ `Proper font found: 'Ostrich Sans Medium' for styled input.`
+ );
+});
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_no-fonts.js b/devtools/client/inspector/fonts/test/browser_fontinspector_no-fonts.js
new file mode 100644
index 0000000000..454cabdf92
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_no-fonts.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Check that the warning message for no fonts found shows up when selecting a node
+// that does not have any used fonts.
+// Ensure that no used fonts are listed.
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+
+add_task(async function () {
+ const { view, inspector } = await openFontInspectorForURL(TEST_URI);
+ const viewDoc = view.document;
+ await selectNode(".empty", inspector);
+
+ info("Test the warning message for no fonts found on empty element");
+ const warning = viewDoc.querySelector(
+ "#font-editor .devtools-sidepanel-no-result"
+ );
+ ok(warning, "The warning for no fonts found is shown for the empty element");
+
+ info("Test that no fonts are listed for the empty element");
+ const fontsEls = getUsedFontsEls(viewDoc);
+ is(fontsEls.length, 0, "There are no used fonts listed");
+});
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_reveal-in-page.js b/devtools/client/inspector/fonts/test/browser_fontinspector_reveal-in-page.js
new file mode 100644
index 0000000000..bcefe0b3bf
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_reveal-in-page.js
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that fonts usage can be revealed in the page using the FontsHighlighter.
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+
+add_task(async function () {
+ // Make sure the toolbox is tall enough to accomodate all fonts, otherwise mouseover
+ // events simulation will fail.
+ await pushPref("devtools.toolbox.footer.height", 500);
+
+ const { view } = await openFontInspectorForURL(TEST_URI);
+ await testFontHighlighting(view);
+
+ info("Check that highlighting still works after reloading the page");
+ await reloadBrowser();
+ await testFontHighlighting(view);
+});
+
+async function testFontHighlighting(view) {
+ // The number of window selection change events we expect to get as we hover over each
+ // font in the list. Waiting for those events is how we know that text-runs were
+ // highlighted in the page.
+ // The reason why these numbers vary is because the highlighter may create more than
+ // 1 selection range object, depending on the number of text-runs found.
+ const expectedSelectionChangeEvents = [2, 2, 2, 1, 1];
+
+ const viewDoc = view.document;
+
+ // Wait for the view to have all the expected used fonts.
+ const fontEls = await waitFor(() => {
+ const els = getUsedFontsEls(viewDoc);
+ if (els.length !== expectedSelectionChangeEvents.length) {
+ return false;
+ }
+
+ return els;
+ });
+
+ for (let i = 0; i < fontEls.length; i++) {
+ info(
+ `Mousing over and out of font number ${i} ("${fontEls[i].textContent}") in the list`
+ );
+
+ // Simulating a mouse over event on the font name and expecting a selectionchange.
+ const nameEl = fontEls[i];
+ let onEvents = waitForNSelectionEvents(expectedSelectionChangeEvents[i]);
+ EventUtils.synthesizeMouse(
+ nameEl,
+ 2,
+ 2,
+ { type: "mouseover" },
+ viewDoc.defaultView
+ );
+ await onEvents;
+
+ ok(
+ true,
+ `${expectedSelectionChangeEvents[i]} selectionchange events detected on mouseover`
+ );
+
+ // Simulating a mouse out event on the font name and expecting a selectionchange.
+ const otherEl = viewDoc.querySelector("body");
+ onEvents = waitForNSelectionEvents(1);
+ EventUtils.synthesizeMouse(
+ otherEl,
+ 2,
+ 2,
+ { type: "mouseover" },
+ viewDoc.defaultView
+ );
+ await onEvents;
+
+ ok(true, "1 selectionchange events detected on mouseout");
+ }
+}
+
+async function waitForNSelectionEvents(numberOfTimes) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [numberOfTimes],
+ async function (n) {
+ const win = content.wrappedJSObject;
+
+ await new Promise(resolve => {
+ let received = 0;
+ win.document.addEventListener("selectionchange", function listen() {
+ received++;
+
+ if (received === n) {
+ win.document.removeEventListener("selectionchange", listen);
+ resolve();
+ }
+ });
+ });
+ }
+ );
+}
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_text-node.js b/devtools/client/inspector/fonts/test/browser_fontinspector_text-node.js
new file mode 100644
index 0000000000..b5e58b9745
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_text-node.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that selecting a text node invokes the font editor on its parent node.
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+
+add_task(async function () {
+ const { inspector, view } = await openFontInspectorForURL(TEST_URI);
+ const viewDoc = view.document;
+
+ info("Select the first text node of <body>");
+ const bodyNode = await getNodeFront("body", inspector);
+ const { nodes } = await inspector.walker.children(bodyNode);
+ const onInspectorUpdated = inspector.once("fontinspector-updated");
+ info("Select the text node");
+ await selectNode(nodes[0], inspector);
+
+ info("Waiting for font editor to render");
+ await onInspectorUpdated;
+
+ const textFonts = getUsedFontsEls(viewDoc);
+
+ info("Select the <body> element");
+ await selectNode("body", inspector);
+
+ const parentFonts = getUsedFontsEls(viewDoc);
+ is(
+ textFonts.length,
+ parentFonts.length,
+ "Font inspector shows same number of fonts"
+ );
+});
diff --git a/devtools/client/inspector/fonts/test/browser_fontinspector_theme-change.js b/devtools/client/inspector/fonts/test/browser_fontinspector_theme-change.js
new file mode 100644
index 0000000000..b3c91a727d
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/browser_fontinspector_theme-change.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+requestLongerTimeout(2);
+
+// Test that the preview images are updated when the theme changes.
+
+const {
+ getTheme,
+ setTheme,
+} = require("resource://devtools/client/shared/theme.js");
+
+const TEST_URI = URL_ROOT + "doc_browser_fontinspector.html";
+const originalTheme = getTheme();
+
+registerCleanupFunction(() => {
+ info(`Restoring theme to '${originalTheme}.`);
+ setTheme(originalTheme);
+});
+
+add_task(async function () {
+ const { inspector, view } = await openFontInspectorForURL(TEST_URI);
+ const viewDoc = view.document;
+
+ await selectNode(".normal-text", inspector);
+ await expandFontsAccordion(viewDoc);
+ const allFontsEls = getAllFontsEls(viewDoc);
+ const fontEl = allFontsEls[0];
+
+ // Store the original preview URI for later comparison.
+ const originalURI = fontEl.querySelector(".font-preview").src;
+ const newTheme = originalTheme === "light" ? "dark" : "light";
+
+ info(`Original theme was '${originalTheme}'.`);
+
+ await setThemeAndWaitForUpdate(newTheme, inspector);
+ isnot(
+ fontEl.querySelector(".font-preview").src,
+ originalURI,
+ "The preview image changed with the theme."
+ );
+
+ await setThemeAndWaitForUpdate(originalTheme, inspector);
+ is(
+ fontEl.querySelector(".font-preview").src,
+ originalURI,
+ "The preview image is correct after the original theme was restored."
+ );
+});
+
+/**
+ * Sets the current theme and waits for fontinspector-updated event.
+ *
+ * @param {String} theme - the new theme
+ * @param {Object} inspector - the inspector panel
+ */
+async function setThemeAndWaitForUpdate(theme, inspector) {
+ const onUpdated = inspector.once("fontinspector-updated");
+
+ info(`Setting theme to '${theme}'.`);
+ setTheme(theme);
+
+ info("Waiting for font-inspector to update.");
+ await onUpdated;
+}
diff --git a/devtools/client/inspector/fonts/test/doc_browser_fontinspector.html b/devtools/client/inspector/fonts/test/doc_browser_fontinspector.html
new file mode 100644
index 0000000000..27e24e2fd0
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/doc_browser_fontinspector.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+
+<style>
+ @font-face {
+ font-family: bar;
+ src: url(bad/font/name.ttf), url(ostrich-regular.ttf) format("truetype");
+ }
+ @font-face {
+ font-family: barnormal;
+ font-weight: normal;
+ src: url(ostrich-regular.ttf);
+ }
+ @font-face {
+ font-family: bar;
+ font-weight: bold;
+ src: url(ostrich-black.ttf);
+ }
+ @font-face {
+ font-family: bar;
+ font-weight: 800;
+ src: url(ostrich-black.ttf);
+ }
+ body{
+ /* Arial doesn't exist on Linux. Liberation Sans is the default sans-serif there. */
+ font-family:Arial, "Liberation Sans";
+ font-size: 36px;
+ }
+ div {
+ font-size: 1em;
+ font-family:bar, "Missing Family", sans-serif;
+ }
+ .normal-text {
+ font-family: barnormal;
+ font-weight: normal;
+ }
+ .bold-text {
+ font-family: bar;
+ font-weight: bold;
+ font-size: inherit;
+ }
+ .black-text {
+ font-family: bar;
+ font-weight: 800;
+ }
+ .viewport-size {
+ font-size: 10vw;
+ }
+ .input-field {
+ font-family: bar;
+ font-size: 36px;
+ color: blue;
+ }
+</style>
+
+<body>
+ BODY
+ <div>DIV
+ <span class="nested-span">NESTED SPAN</span>
+ </div>
+ <iframe src="test_iframe.html"></iframe>
+ <div class="normal-text">NORMAL DIV</div>
+ <div class="bold-text">BOLD DIV</div>
+ <div class="black-text">800 DIV</div>
+ <div class="empty"></div>
+ <div class="viewport-size">VIEWPORT SIZE</div>
+ <input class="input-field" value="Input text value"/>
+</body>
diff --git a/devtools/client/inspector/fonts/test/doc_browser_fontinspector_iframe.html b/devtools/client/inspector/fonts/test/doc_browser_fontinspector_iframe.html
new file mode 100644
index 0000000000..a7ef3385e7
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/doc_browser_fontinspector_iframe.html
@@ -0,0 +1,5 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+<meta charset="utf-8">
+<iframe id="frame" src="doc_browser_fontinspector.html" width="500" height="250"></iframe>
diff --git a/devtools/client/inspector/fonts/test/head.js b/devtools/client/inspector/fonts/test/head.js
new file mode 100644
index 0000000000..058029f13a
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/head.js
@@ -0,0 +1,277 @@
+/* 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/. */
+/* eslint no-unused-vars: [2, {"vars": "local"}] */
+
+"use strict";
+
+// Import the inspector's head.js first (which itself imports shared-head.js).
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/inspector/test/head.js",
+ this
+);
+
+Services.prefs.setCharPref("devtools.inspector.activeSidebar", "fontinspector");
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("devtools.inspector.activeSidebar");
+ Services.prefs.clearUserPref("devtools.inspector.selectedSidebar");
+});
+
+var nodeConstants = require("resource://devtools/shared/dom-node-constants.js");
+
+/**
+ * The font-inspector doesn't participate in the inspector's update mechanism
+ * (i.e. it doesn't call inspector.updating() when updating), so simply calling
+ * the default selectNode isn't enough to guaranty that the panel has finished
+ * updating. We also need to wait for the fontinspector-updated event.
+ *
+ * @param {String|NodeFront} selector
+ * @param {InspectorPanel} inspector
+ * The instance of InspectorPanel currently loaded in the toolbox.
+ * @param {String} reason
+ * Defaults to "test" which instructs the inspector not to highlight the
+ * node upon selection.
+ */
+var _selectNode = selectNode;
+selectNode = async function (node, inspector, reason) {
+ // Ensure node is a NodeFront and not a selector (which is also accepted as
+ // an argument to selectNode).
+ node = await getNodeFront(node, inspector);
+
+ // The FontInspector will fallback to the parent node when a text node is
+ // selected.
+ const isTextNode = node.nodeType == nodeConstants.TEXT_NODE;
+ const expectedNode = isTextNode ? node.parentNode() : node;
+
+ const onEditorUpdated = inspector.once("fonteditor-updated");
+ const onFontInspectorUpdated = new Promise(resolve => {
+ inspector.on("fontinspector-updated", function onUpdated(eventNode) {
+ if (eventNode === expectedNode) {
+ inspector.off("fontinspector-updated", onUpdated);
+ resolve();
+ }
+ });
+ });
+ await _selectNode(node, inspector, reason);
+
+ // Wait for both the font inspector and font editor before proceeding.
+ await Promise.all([onFontInspectorUpdated, onEditorUpdated]);
+};
+
+/**
+ * Adds a new tab with the given URL, opens the inspector and selects the
+ * font-inspector tab.
+ * @return {Promise} resolves to a {tab, toolbox, inspector, view} object
+ */
+var openFontInspectorForURL = async function (url) {
+ const tab = await addTab(url);
+ const { toolbox, inspector } = await openInspector();
+
+ // Call selectNode again here to force a fontinspector update since we don't
+ // know if the fontinspector-updated event has been sent while the inspector
+ // was being opened or not.
+ await selectNode("body", inspector);
+
+ return {
+ tab,
+ toolbox,
+ inspector,
+ view: inspector.getPanel("fontinspector"),
+ };
+};
+
+/**
+ * Focus the preview input, clear it, type new text into it and wait for the
+ * preview images to be updated.
+ *
+ * @param {FontInspector} view - The FontInspector instance.
+ * @param {String} text - The text to preview.
+ */
+async function updatePreviewText(view, text) {
+ info(`Changing the preview text to '${text}'`);
+
+ const doc = view.document;
+ const input = doc.querySelector("#font-preview-input-container input");
+ input.focus();
+
+ info("Blanking the input field.");
+ while (input.value.length) {
+ const update = view.inspector.once("fontinspector-updated");
+ EventUtils.sendKey("BACK_SPACE", doc.defaultView);
+ await update;
+ }
+
+ if (text) {
+ info(`Typing "${text}" into the input field.`);
+ const update = view.inspector.once("fontinspector-updated");
+ EventUtils.sendString(text, doc.defaultView);
+ await update;
+ }
+
+ is(input.value, text, `The input now contains "${text}".`);
+}
+
+/**
+ * Get all of the <li> elements for the fonts used on the currently selected element.
+ *
+ * NOTE: This method is used by tests which check the old Font Inspector. It, along with
+ * the tests should be removed once the Font Editor reaches Firefox Stable.
+ * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1485324
+ *
+ * @param {Document} viewDoc
+ * @return {NodeList}
+ */
+function getUsedFontsEls_obsolete(viewDoc) {
+ return viewDoc.querySelectorAll("#font-editor .fonts-list li");
+}
+
+/**
+ * Get all of the elements with names of fonts used on the currently selected element.
+ *
+ * @param {Document} viewDoc
+ * @return {NodeList}
+ */
+function getUsedFontsEls(viewDoc) {
+ return viewDoc.querySelectorAll(
+ "#font-editor .font-control-used-fonts .font-name"
+ );
+}
+
+/**
+ * Get all of the elements with groups of fonts used on the currently selected element.
+ *
+ * @param {Document} viewDoc
+ * @return {NodeList}
+ */
+function getUsedFontGroupsEls(viewDoc) {
+ return viewDoc.querySelectorAll(
+ "#font-editor .font-control-used-fonts .font-group"
+ );
+}
+
+/**
+ * Get the DOM element for the accordion widget that contains the fonts used elsewhere in
+ * the document.
+ *
+ * @param {Document} viewDoc
+ * @return {DOMNode}
+ */
+function getFontsAccordion(viewDoc) {
+ return viewDoc.querySelector("#font-container .accordion");
+}
+
+/**
+ * Expand a given accordion widget.
+ *
+ * @param {DOMNode} accordion
+ */
+async function expandAccordion(accordion) {
+ const isExpanded = () => accordion.querySelector(".fonts-list");
+ if (isExpanded()) {
+ return;
+ }
+
+ const onExpanded = BrowserTestUtils.waitForCondition(
+ isExpanded,
+ "Waiting for other fonts section"
+ );
+ accordion.querySelector(".theme-twisty").click();
+ await onExpanded;
+}
+
+/**
+ * Expand the fonts accordion.
+ *
+ * @param {Document} viewDoc
+ */
+async function expandFontsAccordion(viewDoc) {
+ info("Expanding the other fonts section");
+ await expandAccordion(getFontsAccordion(viewDoc));
+}
+
+/**
+ * Get all of the <li> elements for the fonts used elsewhere in the document.
+ *
+ * @param {Document} viewDoc
+ * @return {NodeList}
+ */
+function getAllFontsEls(viewDoc) {
+ return getFontsAccordion(viewDoc).querySelectorAll(".fonts-list > li");
+}
+
+/**
+ * Given a font element, return its name.
+ *
+ * @param {DOMNode} fontEl
+ * The font element.
+ * @return {String}
+ * The name of the font as shown in the UI.
+ */
+function getName(fontEl) {
+ return fontEl.querySelector(".font-name").textContent;
+}
+
+/**
+ * Given a font element, return the font's URL.
+ *
+ * @param {DOMNode} fontEl
+ * The font element.
+ * @return {String}
+ * The URL where the font was loaded from as shown in the UI.
+ */
+function getURL(fontEl) {
+ return fontEl.querySelector(".font-origin").textContent;
+}
+
+/**
+ * Given a font element, return its family name.
+ *
+ * @param {DOMNode} fontEl
+ * The font element.
+ * @return {String}
+ * The name of the font family as shown in the UI.
+ */
+function getFamilyName(fontEl) {
+ return fontEl.querySelector(".font-family-name").textContent;
+}
+
+/**
+ * Get the value and unit of a CSS font property or font axis from the font editor.
+ *
+ * @param {Document} viewDoc
+ * Host document of the font inspector panel.
+ * @param {String} name
+ * Font property name or axis tag
+ * @return {Object}
+ * Object with the value and unit of the given font property or axis tag
+ * from the corresponding input fron the font editor.
+ * @Example:
+ * {
+ * value: {String|null}
+ * unit: {String|null}
+ * }
+ */
+function getPropertyValue(viewDoc, name) {
+ const selector = `#font-editor .font-value-input[name=${name}]`;
+ return {
+ // Ensure value input exists before querying its value
+ value:
+ viewDoc.querySelector(selector) &&
+ parseFloat(viewDoc.querySelector(selector).value),
+ // Ensure unit dropdown exists before querying its value
+ unit:
+ viewDoc.querySelector(selector + ` ~ .font-value-select`) &&
+ viewDoc.querySelector(selector + ` ~ .font-value-select`).value,
+ };
+}
+
+/**
+ * Given a font element, check whether its font source is remote.
+ *
+ * @param {DOMNode} fontEl
+ * The font element.
+ * @return {Boolean}
+ */
+function isRemote(fontEl) {
+ return fontEl.querySelector(".font-origin").classList.contains("remote");
+}
diff --git a/devtools/client/inspector/fonts/test/ostrich-black.ttf b/devtools/client/inspector/fonts/test/ostrich-black.ttf
new file mode 100644
index 0000000000..a0ef8fe1c9
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/ostrich-black.ttf
Binary files differ
diff --git a/devtools/client/inspector/fonts/test/ostrich-regular.ttf b/devtools/client/inspector/fonts/test/ostrich-regular.ttf
new file mode 100644
index 0000000000..9682c07350
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/ostrich-regular.ttf
Binary files differ
diff --git a/devtools/client/inspector/fonts/test/test_iframe.html b/devtools/client/inspector/fonts/test/test_iframe.html
new file mode 100644
index 0000000000..29393a9e9a
--- /dev/null
+++ b/devtools/client/inspector/fonts/test/test_iframe.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+
+<style>
+ div{
+ font-family: "Times New Roman";
+ }
+</style>
+
+<body>
+ <div>Hello world</div>
+</body>
diff --git a/devtools/client/inspector/fonts/types.js b/devtools/client/inspector/fonts/types.js
new file mode 100644
index 0000000000..f27625f742
--- /dev/null
+++ b/devtools/client/inspector/fonts/types.js
@@ -0,0 +1,109 @@
+/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+/**
+ * A font variation axis.
+ */
+const fontVariationAxis = (exports.fontVariationAxis = {
+ // The OpenType tag name of the variation axis
+ tag: PropTypes.string,
+
+ // The axis name of the variation axis
+ name: PropTypes.string,
+
+ // The minimum value of the variation axis
+ minValue: PropTypes.number,
+
+ // The maximum value of the variation axis
+ maxValue: PropTypes.number,
+
+ // The default value of the variation axis
+ defaultValue: PropTypes.number,
+});
+
+const fontVariationInstanceValue = (exports.fontVariationInstanceValue = {
+ // The axis name of the variation axis
+ axis: PropTypes.string,
+
+ // The value of the variation axis
+ value: PropTypes.number,
+});
+
+/**
+ * A font variation instance.
+ */
+const fontVariationInstance = (exports.fontVariationInstance = {
+ // The variation instance name of the font
+ name: PropTypes.string,
+
+ // The font variation values for the variation instance of the font
+ values: PropTypes.arrayOf(PropTypes.shape(fontVariationInstanceValue)),
+});
+
+/**
+ * A single font.
+ */
+const font = (exports.font = {
+ // Font family name
+ CSSFamilyName: PropTypes.string,
+
+ // The format of the font
+ format: PropTypes.string,
+
+ // The name of the font
+ name: PropTypes.string,
+
+ // URL for the font preview
+ previewUrl: PropTypes.string,
+
+ // Object containing the CSS rule for the font
+ rule: PropTypes.object,
+
+ // The text of the CSS rule
+ ruleText: PropTypes.string,
+
+ // The URI of the font file
+ URI: PropTypes.string,
+
+ // The variation axes of the font
+ variationAxes: PropTypes.arrayOf(PropTypes.shape(fontVariationAxis)),
+
+ // The variation instances of the font
+ variationInstances: PropTypes.arrayOf(PropTypes.shape(fontVariationInstance)),
+});
+
+exports.fontOptions = {
+ // The current preview text
+ previewText: PropTypes.string,
+};
+
+exports.fontEditor = {
+ // Variable font axes and their values
+ axes: PropTypes.object,
+
+ // Axes values changed at runtime structured like the "values" property
+ // of a fontVariationInstance
+ customInstanceValues: PropTypes.array,
+
+ // Fonts used on the selected element
+ fonts: PropTypes.arrayOf(PropTypes.shape(font)),
+
+ // Font variation instance currently selected
+ instance: PropTypes.shape(fontVariationInstance),
+
+ // CSS font properties defined on the element
+ properties: PropTypes.object,
+};
+
+/**
+ * Font data.
+ */
+exports.fontData = {
+ // All fonts on the current page.
+ allFonts: PropTypes.arrayOf(PropTypes.shape(font)),
+};
diff --git a/devtools/client/inspector/fonts/utils/font-utils.js b/devtools/client/inspector/fonts/utils/font-utils.js
new file mode 100644
index 0000000000..9a86acad49
--- /dev/null
+++ b/devtools/client/inspector/fonts/utils/font-utils.js
@@ -0,0 +1,111 @@
+/* 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";
+
+module.exports = {
+ /**
+ * Given a CSS unit type, get the amount by which to increment a numeric value.
+ * Used as the step attribute in inputs of type "range" or "number".
+ *
+ * @param {String} unit
+ * CSS unit type (px, %, em, rem, vh, vw, ...)
+ * @return {Number}
+ * Amount by which to increment.
+ */
+ getStepForUnit(unit) {
+ let step;
+ switch (unit) {
+ case "":
+ case "em":
+ case "rem":
+ case "vw":
+ case "vh":
+ case "vmin":
+ case "vmax":
+ step = 0.1;
+ break;
+ default:
+ step = 1;
+ }
+
+ return step;
+ },
+
+ /**
+ * Get the unit type from the end of a CSS value string.
+ * Returns null for non-string input or unitless values.
+ *
+ * @param {String} value
+ * CSS value string.
+ * @return {String|null}
+ * CSS unit type, like "px", "em", "rem", etc or null.
+ */
+ getUnitFromValue(value) {
+ if (typeof value !== "string" || isNaN(parseFloat(value))) {
+ return null;
+ }
+
+ const match = value.match(/\D+?$/);
+ return match?.length ? match[0] : null;
+ },
+
+ /**
+ * Parse the string value of CSS font-variation-settings into an object with
+ * axis tag names and corresponding values. If the string is a keyword or does not
+ * contain axes, return an empty object.
+ *
+ * @param {String} string
+ * Value of font-variation-settings property coming from node's computed style.
+ * Its contents are expected to be stable having been already parsed by the
+ * browser.
+ * @return {Object}
+ */
+ parseFontVariationAxes(string) {
+ let axes = {};
+ const keywords = ["initial", "normal", "inherit", "unset"];
+
+ if (!string || keywords.includes(string.trim())) {
+ return axes;
+ }
+
+ // Parse font-variation-settings CSS declaration into an object
+ // with axis tags as keys and axis values as values.
+ axes = string.split(",").reduce((acc, pair) => {
+ // Tags are always in quotes. Split by quote and filter excessive whitespace.
+ pair = pair.split(/["']/).filter(part => part.trim() !== "");
+ // Guard against malformed input that may have slipped through.
+ if (pair.length === 0) {
+ return acc;
+ }
+
+ const tag = pair[0];
+ const value = pair[1].trim();
+ // Axis tags shorter or longer than 4 characters are invalid. Whitespace is valid.
+ if (tag.length === 4) {
+ acc[tag] = parseFloat(value);
+ }
+ return acc;
+ }, {});
+
+ return axes;
+ },
+
+ /**
+ * Limit the decimal count of a number. Used instead of Number.toFixed() which pads
+ * integers with zeroes. If the input is not a number, it is returned as is.
+ *
+ * @param {Number} number
+ * @param {Number} decimals
+ * Decimal count in the output number. Default to one decimal.
+ * @return {Number}
+ */
+ toFixed(number, decimals = 1) {
+ if (typeof number !== "number") {
+ return number;
+ }
+
+ return Math.floor(number * Math.pow(10, decimals)) / Math.pow(10, decimals);
+ },
+};
diff --git a/devtools/client/inspector/fonts/utils/l10n.js b/devtools/client/inspector/fonts/utils/l10n.js
new file mode 100644
index 0000000000..ce2fd0d9e6
--- /dev/null
+++ b/devtools/client/inspector/fonts/utils/l10n.js
@@ -0,0 +1,14 @@
+/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/font-inspector.properties"
+);
+
+module.exports = {
+ getStr: (...args) => L10N.getStr(...args),
+};
diff --git a/devtools/client/inspector/fonts/utils/moz.build b/devtools/client/inspector/fonts/utils/moz.build
new file mode 100644
index 0000000000..ddd06560a0
--- /dev/null
+++ b/devtools/client/inspector/fonts/utils/moz.build
@@ -0,0 +1,10 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "font-utils.js",
+ "l10n.js",
+)