fonts.js (36057B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { 8 gDevTools, 9 } = require("resource://devtools/client/framework/devtools.js"); 10 const { 11 getCssVariableColor, 12 } = require("resource://devtools/client/shared/theme.js"); 13 const { 14 createFactory, 15 createElement, 16 } = require("resource://devtools/client/shared/vendor/react.mjs"); 17 const { 18 Provider, 19 } = require("resource://devtools/client/shared/vendor/react-redux.js"); 20 const { debounce } = require("resource://devtools/shared/debounce.js"); 21 const { 22 style: { ELEMENT_STYLE }, 23 } = require("resource://devtools/shared/constants.js"); 24 25 const FontsApp = createFactory( 26 require("resource://devtools/client/inspector/fonts/components/FontsApp.js") 27 ); 28 29 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 30 const INSPECTOR_L10N = new LocalizationHelper( 31 "devtools/client/locales/inspector.properties" 32 ); 33 34 const { 35 parseFontVariationAxes, 36 } = require("resource://devtools/client/inspector/fonts/utils/font-utils.js"); 37 38 const fontDataReducer = require("resource://devtools/client/inspector/fonts/reducers/fonts.js"); 39 const fontEditorReducer = require("resource://devtools/client/inspector/fonts/reducers/font-editor.js"); 40 const fontOptionsReducer = require("resource://devtools/client/inspector/fonts/reducers/font-options.js"); 41 const { 42 updateFonts, 43 } = require("resource://devtools/client/inspector/fonts/actions/fonts.js"); 44 const { 45 applyInstance, 46 resetFontEditor, 47 setEditorDisabled, 48 updateAxis, 49 updateFontEditor, 50 updateFontProperty, 51 } = require("resource://devtools/client/inspector/fonts/actions/font-editor.js"); 52 const { 53 updatePreviewText, 54 } = require("resource://devtools/client/inspector/fonts/actions/font-options.js"); 55 const { TYPES: HIGHLIGHTER_TYPES } = ChromeUtils.importESModule( 56 "resource://devtools/shared/highlighters.mjs" 57 ); 58 59 const FONT_PROPERTIES = [ 60 "font-family", 61 "font-optical-sizing", 62 "font-size", 63 "font-stretch", 64 "font-style", 65 "font-variation-settings", 66 "font-weight", 67 "letter-spacing", 68 "line-height", 69 ]; 70 const REGISTERED_AXES_TO_FONT_PROPERTIES = { 71 ital: "font-style", 72 opsz: "font-optical-sizing", 73 slnt: "font-style", 74 wdth: "font-stretch", 75 wght: "font-weight", 76 }; 77 const REGISTERED_AXES = Object.keys(REGISTERED_AXES_TO_FONT_PROPERTIES); 78 79 class FontInspector { 80 constructor(inspector, window) { 81 this.cssProperties = inspector.cssProperties; 82 this.document = window.document; 83 this.inspector = inspector; 84 // Selected node in the markup view. For text nodes, this points to their parent node 85 // element. Font faces and font properties for this node will be shown in the editor. 86 this.node = null; 87 this.nodeComputedStyle = {}; 88 // The page style actor that will be providing the style information. 89 this.pageStyle = null; 90 this.ruleViewTool = this.inspector.getPanel("ruleview"); 91 this.ruleView = this.ruleViewTool.view; 92 this.selectedRule = null; 93 this.store = this.inspector.store; 94 // Map CSS property names and variable font axis names to methods that write their 95 // corresponding values to the appropriate TextProperty from the Rule view. 96 // Values of variable font registered axes may be written to CSS font properties under 97 // certain cascade circumstances and platform support. @see `getWriterForAxis(axis)` 98 this.writers = new Map(); 99 100 this.store.injectReducer("fontOptions", fontOptionsReducer); 101 this.store.injectReducer("fontData", fontDataReducer); 102 this.store.injectReducer("fontEditor", fontEditorReducer); 103 104 this.syncChanges = debounce(this.syncChanges, 100, this); 105 this.onInstanceChange = this.onInstanceChange.bind(this); 106 this.onNewNode = this.onNewNode.bind(this); 107 this.onPreviewTextChange = debounce(this.onPreviewTextChange, 100, this); 108 this.onPropertyChange = this.onPropertyChange.bind(this); 109 this.onRulePropertyUpdated = debounce( 110 this.onRulePropertyUpdated, 111 300, 112 this 113 ); 114 this.onToggleFontHighlight = this.onToggleFontHighlight.bind(this); 115 this.onThemeChanged = this.onThemeChanged.bind(this); 116 this.update = this.update.bind(this); 117 this.updateFontVariationSettings = 118 this.updateFontVariationSettings.bind(this); 119 120 this.init(); 121 } 122 123 /** 124 * Map CSS font property names to a list of values that should be skipped when consuming 125 * font properties from CSS rules. The skipped values are mostly keyword values like 126 * `bold`, `initial`, `unset`. Computed values will be used instead of such keywords. 127 * 128 * @return {Map} 129 */ 130 get skipValuesMap() { 131 if (!this._skipValuesMap) { 132 this._skipValuesMap = new Map(); 133 134 for (const property of FONT_PROPERTIES) { 135 const values = this.cssProperties.getValues(property); 136 137 switch (property) { 138 case "line-height": 139 case "letter-spacing": 140 // There's special handling for "normal" so remove it from the skip list. 141 this.skipValuesMap.set( 142 property, 143 values.filter(value => value !== "normal") 144 ); 145 break; 146 default: 147 this.skipValuesMap.set(property, values); 148 } 149 } 150 } 151 152 return this._skipValuesMap; 153 } 154 155 init() { 156 if (!this.inspector) { 157 return; 158 } 159 160 const fontsApp = FontsApp({ 161 onInstanceChange: this.onInstanceChange, 162 onToggleFontHighlight: this.onToggleFontHighlight, 163 onPreviewTextChange: this.onPreviewTextChange, 164 onPropertyChange: this.onPropertyChange, 165 }); 166 167 const provider = createElement( 168 Provider, 169 { 170 id: "fontinspector", 171 key: "fontinspector", 172 store: this.store, 173 title: INSPECTOR_L10N.getStr("inspector.sidebar.fontInspectorTitle"), 174 }, 175 fontsApp 176 ); 177 178 // Expose the provider to let inspector.js use it in setupSidebar. 179 this.provider = provider; 180 181 this.inspector.selection.on("new-node-front", this.onNewNode); 182 // @see ToolSidebar.onSidebarTabSelected() 183 this.inspector.sidebar.on("fontinspector-selected", this.onNewNode); 184 185 // Listen for theme changes as the color of the previews depend on the theme 186 gDevTools.on("theme-switched", this.onThemeChanged); 187 } 188 189 /** 190 * Convert a value for font-size between two CSS unit types. 191 * Conversion is done via pixels. If neither of the two given unit types is "px", 192 * recursively get the value in pixels, then convert that result to the desired unit. 193 * 194 * @param {string} property 195 * Property name for the converted value. 196 * Assumed to be "font-size", but special case for "line-height". 197 * @param {number} value 198 * Numeric value to convert. 199 * @param {string} fromUnit 200 * CSS unit to convert from. 201 * @param {string} toUnit 202 * CSS unit to convert to. 203 * @return {number} 204 * Converted numeric value. 205 */ 206 async convertUnits(property, value, fromUnit, toUnit) { 207 if (value !== parseFloat(value)) { 208 throw TypeError( 209 `Invalid value for conversion. Expected Number, got ${value}` 210 ); 211 } 212 213 const shouldReturn = () => { 214 // Early return if: 215 // - conversion is not required 216 // - property is `line-height` 217 // - `fromUnit` is `em` and `toUnit` is unitless 218 const conversionNotRequired = fromUnit === toUnit || value === 0; 219 const forLineHeight = 220 property === "line-height" && fromUnit === "" && toUnit === "em"; 221 const isEmToUnitlessConversion = fromUnit === "em" && toUnit === ""; 222 return conversionNotRequired || forLineHeight || isEmToUnitlessConversion; 223 }; 224 225 if (shouldReturn()) { 226 return value; 227 } 228 229 // If neither unit is in pixels, first convert the value to pixels. 230 // Reassign input value and source CSS unit. 231 if (toUnit !== "px" && fromUnit !== "px") { 232 value = await this.convertUnits(property, value, fromUnit, "px"); 233 fromUnit = "px"; 234 } 235 236 // Whether the conversion is done from pixels. 237 const fromPx = fromUnit === "px"; 238 // Determine the target CSS unit for conversion. 239 const unit = toUnit === "px" ? fromUnit : toUnit; 240 // Default output value to input value for a 1-to-1 conversion as a guard against 241 // unrecognized CSS units. It will not be correct, but it will also not break. 242 let out = value; 243 244 const converters = { 245 in: () => (fromPx ? value / 96 : value * 96), 246 cm: () => (fromPx ? value * 0.02645833333 : value / 0.02645833333), 247 mm: () => (fromPx ? value * 0.26458333333 : value / 0.26458333333), 248 pt: () => (fromPx ? value * 0.75 : value / 0.75), 249 pc: () => (fromPx ? value * 0.0625 : value / 0.0625), 250 "%": async () => { 251 const fontSize = await this.getReferenceFontSize(property, unit); 252 return fromPx 253 ? (value * 100) / parseFloat(fontSize) 254 : (value / 100) * parseFloat(fontSize); 255 }, 256 rem: async () => { 257 const fontSize = await this.getReferenceFontSize(property, unit); 258 return fromPx 259 ? value / parseFloat(fontSize) 260 : value * parseFloat(fontSize); 261 }, 262 vh: async () => { 263 const { height } = await this.getReferenceBox(property, unit); 264 return fromPx ? (value * 100) / height : (value / 100) * height; 265 }, 266 vw: async () => { 267 const { width } = await this.getReferenceBox(property, unit); 268 return fromPx ? (value * 100) / width : (value / 100) * width; 269 }, 270 vmin: async () => { 271 const { width, height } = await this.getReferenceBox(property, unit); 272 return fromPx 273 ? (value * 100) / Math.min(width, height) 274 : (value / 100) * Math.min(width, height); 275 }, 276 vmax: async () => { 277 const { width, height } = await this.getReferenceBox(property, unit); 278 return fromPx 279 ? (value * 100) / Math.max(width, height) 280 : (value / 100) * Math.max(width, height); 281 }, 282 }; 283 284 if (converters.hasOwnProperty(unit)) { 285 const converter = converters[unit]; 286 out = await converter(); 287 } 288 289 // Special handling for unitless line-height. 290 if (unit === "em" || (unit === "" && property === "line-height")) { 291 const fontSize = await this.getReferenceFontSize(property, unit); 292 out = fromPx 293 ? value / parseFloat(fontSize) 294 : value * parseFloat(fontSize); 295 } 296 297 // Catch any NaN or Infinity as result of dividing by zero in any 298 // of the relative unit conversions which rely on external values. 299 if (isNaN(out) || Math.abs(out) === Infinity) { 300 out = 0; 301 } 302 303 // Return values limited to 3 decimals when: 304 // - the unit is converted from pixels to something else 305 // - the value is for letter spacing, regardless of unit (allow sub-pixel precision) 306 if (fromPx || property === "letter-spacing") { 307 // Round values like 1.000 to 1 308 return out === Math.round(out) ? Math.round(out) : out.toFixed(3); 309 } 310 311 // Round pixel values. 312 return Math.round(out); 313 } 314 315 /** 316 * Destruction function called when the inspector is destroyed. Removes event listeners 317 * and cleans up references. 318 */ 319 destroy() { 320 this.inspector.selection.off("new-node-front", this.onNewNode); 321 this.inspector.sidebar.off("fontinspector-selected", this.onNewNode); 322 this.ruleView.off("property-value-updated", this.onRulePropertyUpdated); 323 gDevTools.off("theme-switched", this.onThemeChanged); 324 325 this.document = null; 326 this.inspector = null; 327 this.node = null; 328 this.nodeComputedStyle = {}; 329 this.pageStyle = null; 330 this.ruleView = null; 331 this.selectedRule = null; 332 this.store = null; 333 this.writers.clear(); 334 this.writers = null; 335 } 336 337 /** 338 * Get all expected CSS font properties and values from the node's matching rules and 339 * fallback to computed style. Skip CSS Custom Properties, `calc()` and keyword values. 340 * 341 * @return {object} 342 */ 343 async getFontProperties() { 344 const properties = {}; 345 346 // First, get all expected font properties from computed styles, if available. 347 for (const prop of FONT_PROPERTIES) { 348 properties[prop] = 349 this.nodeComputedStyle[prop] && this.nodeComputedStyle[prop].value 350 ? this.nodeComputedStyle[prop].value 351 : ""; 352 } 353 354 // Then, replace with enabled font properties found on any of the rules that apply. 355 for (const rule of this.ruleView.rules) { 356 if (rule.inherited) { 357 continue; 358 } 359 360 for (const textProp of rule.textProps) { 361 if ( 362 FONT_PROPERTIES.includes(textProp.name) && 363 !this.skipValuesMap.get(textProp.name).includes(textProp.value) && 364 !textProp.value.includes("calc(") && 365 !textProp.value.includes("var(") && 366 !textProp.overridden && 367 textProp.enabled 368 ) { 369 properties[textProp.name] = textProp.value; 370 } 371 } 372 } 373 374 return properties; 375 } 376 377 async getFontsForNode(node, options) { 378 // In case we've been destroyed in the meantime 379 if (!this.document) { 380 return []; 381 } 382 383 const fonts = await this.pageStyle 384 .getUsedFontFaces(node, options) 385 .catch(console.error); 386 if (!fonts) { 387 return []; 388 } 389 390 return fonts; 391 } 392 393 async getAllFonts(options) { 394 // In case we've been destroyed in the meantime 395 if (!this.document) { 396 return []; 397 } 398 399 const inspectorFronts = await this.inspector.getAllInspectorFronts(); 400 401 let allFonts = []; 402 for (const { pageStyle } of inspectorFronts) { 403 allFonts = allFonts.concat(await pageStyle.getAllUsedFontFaces(options)); 404 } 405 406 return allFonts; 407 } 408 409 /** 410 * Get the box dimensions used for unit conversion according to the CSS property and 411 * target CSS unit. 412 * 413 * @param {string} property 414 * CSS property 415 * @param {string} unit 416 * Target CSS unit 417 * @return {Promise} 418 * Promise that resolves with an object with box dimensions in pixels. 419 */ 420 async getReferenceBox(property, unit) { 421 const box = { width: 0, height: 0 }; 422 const node = await this.getReferenceNode(property, unit).catch( 423 console.error 424 ); 425 426 if (!node) { 427 return box; 428 } 429 430 switch (unit) { 431 case "vh": 432 case "vw": 433 case "vmin": 434 case "vmax": { 435 const dim = await node.getOwnerGlobalDimensions().catch(console.error); 436 if (dim) { 437 box.width = dim.innerWidth; 438 box.height = dim.innerHeight; 439 } 440 break; 441 } 442 case "%": { 443 const style = await this.pageStyle 444 .getComputed(node) 445 .catch(console.error); 446 if (style) { 447 box.width = style.width.value; 448 box.height = style.height.value; 449 } 450 break; 451 } 452 } 453 454 return box; 455 } 456 457 /** 458 * Get the refernece font size value used for unit conversion according to the 459 * CSS property and target CSS unit. 460 * 461 * @param {string} property 462 * CSS property 463 * @param {string} unit 464 * Target CSS unit 465 * @return {Promise} 466 * Promise that resolves with the reference font size value or null if there 467 * was an error getting that value. 468 */ 469 async getReferenceFontSize(property, unit) { 470 const node = await this.getReferenceNode(property, unit).catch( 471 console.error 472 ); 473 if (!node) { 474 return null; 475 } 476 477 const style = await this.pageStyle.getComputed(node).catch(console.error); 478 if (!style) { 479 return null; 480 } 481 482 return style["font-size"].value; 483 } 484 485 /** 486 * Get the reference node used in measurements for unit conversion according to the 487 * the CSS property and target CSS unit type. 488 * 489 * @param {string} property 490 * CSS property 491 * @param {string} unit 492 * Target CSS unit 493 * @return {Promise} 494 * Promise that resolves with the reference node used in measurements for unit 495 * conversion. 496 */ 497 async getReferenceNode(property, unit) { 498 let node; 499 500 switch (property) { 501 case "line-height": 502 case "letter-spacing": 503 node = this.node; 504 break; 505 default: 506 node = this.node.parentNode(); 507 } 508 509 switch (unit) { 510 case "rem": 511 // Regardless of CSS property, always use the root document element for "rem". 512 node = await this.node.walkerFront.documentElement(); 513 break; 514 } 515 516 return node; 517 } 518 519 /** 520 * Get a reference to a TextProperty instance from the current selected rule for a 521 * given property name. 522 * 523 * @param {string} name 524 * CSS property name 525 * @return {TextProperty|null} 526 */ 527 getTextProperty(name) { 528 if (!this.selectedRule) { 529 return null; 530 } 531 532 return this.selectedRule.textProps.find( 533 prop => prop.name === name && prop.enabled && !prop.overridden 534 ); 535 } 536 537 /** 538 * Given the axis name of a registered axis, return a method which updates the 539 * corresponding CSS font property when called with a value. 540 * 541 * All variable font axes can be written in the value of the "font-variation-settings" 542 * CSS font property. In CSS Fonts Level 4, registered axes values can be used as 543 * values of font properties, like "font-weight", "font-stretch" and "font-style". 544 * 545 * Axes declared in "font-variation-settings", either on the rule or inherited, 546 * overwrite any corresponding font properties. Updates to these axes must be written 547 * to "font-variation-settings" to preserve the cascade. Authors are discouraged from 548 * using this practice. Whenever possible, registered axes values should be written to 549 * their corresponding font properties. 550 * 551 * Registered axis name to font property mapping: 552 * - wdth -> font-stretch 553 * - wght -> font-weight 554 * - opsz -> font-optical-sizing 555 * - slnt -> font-style 556 * - ital -> font-style 557 * 558 * @param {string} axis 559 * Name of registered axis. 560 * @return {Function} 561 * Method to call which updates the corresponding CSS font property. 562 */ 563 getWriterForAxis(axis) { 564 // Find any declaration of "font-variation-setttings". 565 const FVSComputedStyle = this.nodeComputedStyle["font-variation-settings"]; 566 567 // If "font-variation-settings" CSS property is defined (on the rule or inherited) 568 // and contains a declaration for the given registered axis, write to it. 569 if (FVSComputedStyle && FVSComputedStyle.value.includes(axis)) { 570 return this.updateFontVariationSettings; 571 } 572 573 // Get corresponding CSS font property value for registered axis. 574 const property = REGISTERED_AXES_TO_FONT_PROPERTIES[axis]; 575 576 return value => { 577 let condition = false; 578 579 switch (axis) { 580 case "wght": 581 // Whether the page supports values of font-weight from CSS Fonts Level 4. 582 condition = this.pageStyle.supportsFontWeightLevel4; 583 break; 584 585 case "wdth": 586 // font-stretch in CSS Fonts Level 4 accepts percentage units. 587 value = `${value}%`; 588 // Whether the page supports values of font-stretch from CSS Fonts Level 4. 589 condition = this.pageStyle.supportsFontStretchLevel4; 590 break; 591 592 case "slnt": 593 // font-style in CSS Fonts Level 4 accepts an angle value. 594 // We have to invert the sign of the angle because CSS and OpenType measure 595 // in opposite directions. 596 value = -value; 597 value = `oblique ${value}deg`; 598 // Whether the page supports values of font-style from CSS Fonts Level 4. 599 condition = this.pageStyle.supportsFontStyleLevel4; 600 break; 601 } 602 603 if (condition) { 604 this.updatePropertyValue(property, value); 605 } else { 606 // Replace the writer method for this axis so it won't get called next time. 607 this.writers.set(axis, this.updateFontVariationSettings); 608 // Fall back to writing to font-variation-settings together with all other axes. 609 this.updateFontVariationSettings(); 610 } 611 }; 612 } 613 614 /** 615 * Given a CSS property name or axis name of a variable font, return a method which 616 * updates the corresponding CSS font property when called with a value. 617 * 618 * This is used to distinguish between CSS font properties, registered axes and 619 * custom axes. Registered axes, like "wght" and "wdth", should be written to 620 * corresponding CSS properties, like "font-weight" and "font-stretch". 621 * 622 * Unrecognized names (which aren't font property names or registered axes names) are 623 * considered to be custom axes names and will be written to the 624 * "font-variation-settings" CSS property. 625 * 626 * @param {string} name 627 * CSS property name or axis name. 628 * @return {Function} 629 * Method which updates the rule view and page style. 630 */ 631 getWriterForProperty(name) { 632 if (this.writers.has(name)) { 633 return this.writers.get(name); 634 } 635 636 if (REGISTERED_AXES.includes(name)) { 637 this.writers.set(name, this.getWriterForAxis(name)); 638 } else if (FONT_PROPERTIES.includes(name)) { 639 this.writers.set(name, value => { 640 this.updatePropertyValue(name, value); 641 }); 642 } else { 643 this.writers.set(name, this.updateFontVariationSettings); 644 } 645 646 return this.writers.get(name); 647 } 648 649 /** 650 * Check if the font inspector panel is visible. 651 * 652 * @return {boolean} 653 */ 654 isPanelVisible() { 655 return ( 656 this.inspector && 657 this.inspector.sidebar && 658 this.inspector.sidebar.getCurrentTabID() === "fontinspector" 659 ); 660 } 661 662 /** 663 * Upon a new node selection, log some interesting telemetry probes. 664 */ 665 logTelemetryProbesOnNewNode() { 666 const { fontEditor } = this.store.getState(); 667 668 // Log data about the currently edited font (if any). 669 // Note that the edited font is always the first one from the fontEditor.fonts array. 670 const editedFont = fontEditor.fonts[0]; 671 if (!editedFont) { 672 return; 673 } 674 675 const nbOfAxes = editedFont.variationAxes 676 ? editedFont.variationAxes.length 677 : 0; 678 Glean.devtoolsInspector.fonteditorFontTypeDisplayed[ 679 !nbOfAxes ? "nonvariable" : "variable" 680 ].add(1); 681 } 682 683 /** 684 * Sync the Rule view with the latest styles from the page. Called in a debounced way 685 * (see constructor) after property changes are applied directly to the CSS style rule 686 * on the page circumventing direct TextProperty.setValue() which triggers expensive DOM 687 * operations in TextPropertyEditor.update(). 688 * 689 * @param {string} name 690 * CSS property name 691 * @param {string} value 692 * CSS property value 693 */ 694 async syncChanges(name, value) { 695 const textProperty = this.getTextProperty(name, value); 696 if (textProperty) { 697 try { 698 await textProperty.setValue(value, "", true); 699 this.ruleView.on("property-value-updated", this.onRulePropertyUpdated); 700 } catch (error) { 701 // Because setValue() does an asynchronous call to the server, there is a chance 702 // the font editor was destroyed while we were waiting. If that happened, just 703 // bail out silently. 704 if (!this.document) { 705 return; 706 } 707 708 throw error; 709 } 710 } 711 } 712 713 /** 714 * Handler for changes of a font axis value coming from the FontEditor. 715 * 716 * @param {string} tag 717 * Tag name of the font axis. 718 * @param {number} value 719 * Value of the font axis. 720 */ 721 onAxisUpdate(tag, value) { 722 this.store.dispatch(updateAxis(tag, value)); 723 const writer = this.getWriterForProperty(tag); 724 writer(value.toString()); 725 } 726 727 /** 728 * Handler for changes of a CSS font property value coming from the FontEditor. 729 * 730 * @param {string} property 731 * CSS font property name. 732 * @param {number} value 733 * CSS font property numeric value. 734 * @param {string | null} unit 735 * CSS unit or null 736 */ 737 onFontPropertyUpdate(property, value, unit) { 738 value = unit !== null ? value + unit : value; 739 this.store.dispatch(updateFontProperty(property, value)); 740 const writer = this.getWriterForProperty(property); 741 writer(value.toString()); 742 } 743 744 /** 745 * Handler for selecting a font variation instance. Dispatches an action which updates 746 * the axes and their values as defined by that variation instance. 747 * 748 * @param {string} name 749 * Name of variation instance. (ex: Light, Regular, Ultrabold, etc.) 750 * @param {Array} values 751 * Array of objects with axes and values defined by the variation instance. 752 */ 753 onInstanceChange(name, values) { 754 this.store.dispatch(applyInstance(name, values)); 755 let writer; 756 values.map(obj => { 757 writer = this.getWriterForProperty(obj.axis); 758 writer(obj.value.toString()); 759 }); 760 } 761 762 /** 763 * Event handler for "new-node-front" event fired when a new node is selected in the 764 * markup view. 765 * 766 * Sets the selected node for which font faces and font properties will be 767 * shown in the font editor. If the selection is a text node, use its parent element. 768 * 769 * Triggers a refresh of the font editor and font overview if the panel is visible. 770 */ 771 onNewNode() { 772 this.ruleView.off("property-value-updated", this.onRulePropertyUpdated); 773 774 // First, reset the selected node and page style front. 775 this.node = null; 776 this.pageStyle = null; 777 778 // Then attempt to assign a selected node according to its type. 779 const selection = this.inspector && this.inspector.selection; 780 if (selection && selection.isConnected()) { 781 if (selection.isElementNode()) { 782 this.node = selection.nodeFront; 783 } else if (selection.isTextNode()) { 784 this.node = selection.nodeFront.parentNode(); 785 } 786 787 this.pageStyle = this.node.inspectorFront.pageStyle; 788 } 789 790 if (this.isPanelVisible()) { 791 Promise.all([this.update(), this.refreshFontEditor()]) 792 .then(() => { 793 this.logTelemetryProbesOnNewNode(); 794 }) 795 .catch(e => console.error(e)); 796 } 797 } 798 799 /** 800 * Handler for change in preview input. 801 */ 802 onPreviewTextChange(value) { 803 this.store.dispatch(updatePreviewText(value)); 804 this.update(); 805 } 806 807 /** 808 * Handler for changes to any CSS font property value or variable font axis value coming 809 * from the Font Editor. This handler calls the appropriate method to preview the 810 * changes on the page and update the store. 811 * 812 * If the property parameter is not a recognized CSS font property name, assume it's a 813 * variable font axis name. 814 * 815 * @param {string} property 816 * CSS font property name or axis name 817 * @param {number} value 818 * CSS font property value or axis value 819 * @param {string | undefined} fromUnit 820 * Optional CSS unit to convert from 821 * @param {string | undefined} toUnit 822 * Optional CSS unit to convert to 823 */ 824 async onPropertyChange(property, value, fromUnit, toUnit) { 825 if (FONT_PROPERTIES.includes(property)) { 826 let unit = fromUnit; 827 828 // Strict checks because "line-height" value may be unitless (empty string). 829 if (toUnit !== undefined && fromUnit !== undefined) { 830 value = await this.convertUnits(property, value, fromUnit, toUnit); 831 unit = toUnit; 832 } 833 834 this.onFontPropertyUpdate(property, value, unit); 835 } else { 836 this.onAxisUpdate(property, value); 837 } 838 } 839 840 /** 841 * Handler for "property-value-updated" event emitted from the rule view whenever a 842 * property value changes. Ignore changes to properties unrelated to the font editor. 843 * 844 * @param {object} eventData 845 * Object with the property name and value and origin rule. 846 * Example: { name: "font-size", value: "1em", rule: Object } 847 */ 848 async onRulePropertyUpdated(eventData) { 849 if (!this.selectedRule || !FONT_PROPERTIES.includes(eventData.property)) { 850 return; 851 } 852 853 if (this.isPanelVisible()) { 854 await this.refreshFontEditor(); 855 } 856 } 857 858 /** 859 * Reveal a font's usage in the page. 860 * 861 * @param {string} font 862 * The name of the font to be revealed in the page. 863 * @param {boolean} show 864 * Whether or not to reveal the font. 865 * @param {boolean} isForCurrentElement 866 * Optional. Default `true`. Whether or not to restrict revealing the font 867 * just to the current element selection. 868 */ 869 async onToggleFontHighlight(font, show, isForCurrentElement = true) { 870 try { 871 if (show) { 872 const node = isForCurrentElement 873 ? this.inspector.selection.nodeFront 874 : this.node.walkerFront.rootNode; 875 876 await this.inspector.highlighters.showHighlighterTypeForNode( 877 HIGHLIGHTER_TYPES.FONTS, 878 node, 879 { 880 CSSFamilyName: font.CSSFamilyName, 881 name: font.name, 882 } 883 ); 884 } else { 885 await this.inspector.highlighters.hideHighlighterType( 886 HIGHLIGHTER_TYPES.FONTS 887 ); 888 } 889 } catch (e) { 890 // Silently handle protocol errors here, because these might be called during 891 // shutdown of the browser or devtools, and we don't care if they fail. 892 } 893 } 894 895 /** 896 * Handler for the "theme-switched" event. 897 */ 898 onThemeChanged(frame) { 899 if (frame === this.document.defaultView) { 900 this.update(); 901 } 902 } 903 904 /** 905 * Update the state of the font editor with: 906 * - the fonts which apply to the current node; 907 * - the computed style CSS font properties of the current node. 908 * 909 * This method is called: 910 * - when a new node is selected; 911 * - when any property is changed in the Rule view. 912 * For the latter case, we compare between the latest computed style font properties 913 * and the ones already in the store to decide if to update the font editor state. 914 */ 915 async refreshFontEditor() { 916 if (!this.node) { 917 this.store.dispatch(resetFontEditor()); 918 return; 919 } 920 921 const options = {}; 922 if (this.pageStyle.supportsFontVariations) { 923 options.includeVariations = true; 924 } 925 926 const fonts = await this.getFontsForNode(this.node, options); 927 928 try { 929 // Get computed styles for the selected node, but filter by CSS font properties. 930 this.nodeComputedStyle = await this.pageStyle.getComputed(this.node, { 931 filterProperties: FONT_PROPERTIES, 932 }); 933 } catch (e) { 934 // Because getComputed is async, there is a chance the font editor was 935 // destroyed while we were waiting. If that happened, just bail out 936 // silently. 937 if (!this.document) { 938 return; 939 } 940 941 throw e; 942 } 943 944 if (!this.nodeComputedStyle || !fonts.length) { 945 this.store.dispatch(resetFontEditor()); 946 this.inspector.emit("fonteditor-updated"); 947 return; 948 } 949 950 // Clear any references to writer methods and CSS declarations because the node's 951 // styles may have changed since the last font editor refresh. 952 this.writers.clear(); 953 954 // If the Rule panel is not visible, the selected element's rule models may not have 955 // been created yet. For example, in 2-pane mode when Fonts is opened as the default 956 // panel. Select the current node to force the Rule view to create the rule models. 957 if (!this.ruleViewTool.isPanelVisible()) { 958 await this.ruleView.selectElement(this.node, false); 959 } 960 961 // Select the node's inline style as the rule where to write property value changes. 962 this.selectedRule = this.ruleView.rules.find( 963 rule => rule.domRule.type === ELEMENT_STYLE 964 ); 965 966 const properties = await this.getFontProperties(); 967 // Assign writer methods to each axis defined in font-variation-settings. 968 const axes = parseFontVariationAxes(properties["font-variation-settings"]); 969 Object.keys(axes).map(axis => { 970 this.writers.set(axis, this.getWriterForAxis(axis)); 971 }); 972 973 this.store.dispatch(updateFontEditor(fonts, properties, this.node.actorID)); 974 this.store.dispatch(setEditorDisabled(this.node.isPseudoElement)); 975 976 this.inspector.emit("fonteditor-updated"); 977 // Listen to manual changes in the Rule view that could update the Font Editor state 978 this.ruleView.on("property-value-updated", this.onRulePropertyUpdated); 979 } 980 981 async update() { 982 // Stop refreshing if the inspector or store is already destroyed. 983 if (!this.inspector || !this.store) { 984 return; 985 } 986 987 let allFonts = []; 988 989 if (!this.node) { 990 this.store.dispatch(updateFonts(allFonts)); 991 return; 992 } 993 994 const { fontOptions } = this.store.getState(); 995 const { previewText } = fontOptions; 996 997 const options = { 998 includePreviews: true, 999 // Coerce the type of `supportsFontVariations` to a boolean. 1000 includeVariations: !!this.pageStyle.supportsFontVariations, 1001 previewText, 1002 previewFillStyle: getCssVariableColor( 1003 "--theme-body-color", 1004 this.document.ownerGlobal 1005 ), 1006 }; 1007 1008 // If there are no fonts used on the page, the result is an empty array. 1009 allFonts = await this.getAllFonts(options); 1010 1011 // Augment each font object with a dataURI for an image with a sample of the font. 1012 for (const font of [...allFonts]) { 1013 font.previewUrl = await font.preview.data.string(); 1014 } 1015 1016 // Dispatch to the store if it hasn't been destroyed in the meantime. 1017 this.store && this.store.dispatch(updateFonts(allFonts)); 1018 // Emit on the inspector if it hasn't been destroyed in the meantime. 1019 // Pass the current node in the payload so that tests can check the update 1020 // corresponds to the expected node. 1021 this.inspector && 1022 this.inspector.emitForTests("fontinspector-updated", this.node); 1023 } 1024 1025 /** 1026 * Update the "font-variation-settings" CSS property with the state of all touched 1027 * font variation axes which shouldn't be written to other CSS font properties. 1028 */ 1029 updateFontVariationSettings() { 1030 const fontEditor = this.store.getState().fontEditor; 1031 const name = "font-variation-settings"; 1032 const value = Object.keys(fontEditor.axes) 1033 // Pick only axes which are supposed to be written to font-variation-settings. 1034 // Skip registered axes which should be written to a different CSS property. 1035 .filter(tag => this.writers.get(tag) === this.updateFontVariationSettings) 1036 // Build a string value for the "font-variation-settings" CSS property 1037 .map(tag => `"${tag}" ${fontEditor.axes[tag]}`) 1038 .join(", "); 1039 1040 this.updatePropertyValue(name, value); 1041 } 1042 1043 /** 1044 * Preview a property value (live) then sync the changes (debounced) to the Rule view. 1045 * 1046 * NOTE: Until Bug 1462591 is addressed, all changes are written to the element's inline 1047 * style attribute. In this current scenario, Rule.previewPropertyValue() 1048 * causes the whole inline style representation in the Rule view to update instead of 1049 * just previewing the change on the element. 1050 * We keep the debounced call to syncChanges() because it explicitly calls 1051 * TextProperty.setValue() which performs other actions, including marking the property 1052 * as "changed" in the Rule view with a green indicator. 1053 * 1054 * @param {string} name 1055 * CSS property name 1056 * @param {string}value 1057 * CSS property value 1058 */ 1059 updatePropertyValue(name, value) { 1060 const textProperty = this.getTextProperty(name); 1061 1062 if (!textProperty) { 1063 this.selectedRule.createProperty(name, value, "", true); 1064 return; 1065 } 1066 1067 if (textProperty.value === value) { 1068 return; 1069 } 1070 1071 // Prevent reacting to changes we caused. 1072 this.ruleView.off("property-value-updated", this.onRulePropertyUpdated); 1073 // Live preview font property changes on the page. 1074 textProperty.rule 1075 .previewPropertyValue(textProperty, value, "") 1076 .catch(console.error); 1077 1078 // Sync Rule view with changes reflected on the page (debounced). 1079 this.syncChanges(name, value); 1080 } 1081 } 1082 1083 module.exports = FontInspector;