tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

FontPropertyValue.js (12896B)


      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  createElement,
      9  Fragment,
     10  PureComponent,
     11 } = require("resource://devtools/client/shared/vendor/react.mjs");
     12 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
     13 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
     14 
     15 const {
     16  toFixed,
     17 } = require("resource://devtools/client/inspector/fonts/utils/font-utils.js");
     18 
     19 class FontPropertyValue extends PureComponent {
     20  static get propTypes() {
     21    return {
     22      // Whether to allow input values above the value defined by the `max` prop.
     23      allowOverflow: PropTypes.bool,
     24      // Whether to allow input values below the value defined by the `min` prop.
     25      allowUnderflow: PropTypes.bool,
     26      className: PropTypes.string,
     27      defaultValue: PropTypes.number,
     28      disabled: PropTypes.bool.isRequired,
     29      label: PropTypes.string.isRequired,
     30      min: PropTypes.number.isRequired,
     31      // Whether to show the `min` prop value as a label.
     32      minLabel: PropTypes.bool,
     33      max: PropTypes.number.isRequired,
     34      // Whether to show the `max` prop value as a label.
     35      maxLabel: PropTypes.bool,
     36      name: PropTypes.string.isRequired,
     37      // Whether to show the `name` prop value as an extra label (used to show axis tags).
     38      nameLabel: PropTypes.bool,
     39      onChange: PropTypes.func.isRequired,
     40      step: PropTypes.number,
     41      // Whether to show the value input field.
     42      showInput: PropTypes.bool,
     43      // Whether to show the unit select dropdown.
     44      showUnit: PropTypes.bool,
     45      unit: PropTypes.string,
     46      unitOptions: PropTypes.array,
     47      value: PropTypes.number,
     48      valueLabel: PropTypes.string,
     49    };
     50  }
     51 
     52  static get defaultProps() {
     53    return {
     54      allowOverflow: false,
     55      allowUnderflow: false,
     56      className: "",
     57      minLabel: false,
     58      maxLabel: false,
     59      nameLabel: false,
     60      step: 1,
     61      showInput: true,
     62      showUnit: true,
     63      unit: null,
     64      unitOptions: [],
     65    };
     66  }
     67 
     68  constructor(props) {
     69    super(props);
     70    this.state = {
     71      // Whether the user is dragging the slider thumb or pressing on the numeric stepper.
     72      interactive: false,
     73      // Snapshot of the value from props before the user starts editing the number input.
     74      // Used to restore the value when the input is left invalid.
     75      initialValue: this.props.value,
     76      // Snapshot of the value from props. Reconciled with props on blur.
     77      // Used while the user is interacting with the inputs.
     78      value: this.props.value,
     79    };
     80 
     81    this.onBlur = this.onBlur.bind(this);
     82    this.onChange = this.onChange.bind(this);
     83    this.onFocus = this.onFocus.bind(this);
     84    this.onMouseDown = this.onMouseDown.bind(this);
     85    this.onMouseUp = this.onMouseUp.bind(this);
     86    this.onUnitChange = this.onUnitChange.bind(this);
     87  }
     88 
     89  /**
     90   * Given a `prop` key found on the component's props, check the matching `propLabel`.
     91   * If `propLabel` is true, return the `prop` value; Otherwise, return null.
     92   *
     93   * @param {string} prop
     94   *        Key found on the component's props.
     95   * @return {number | null}
     96   */
     97  getPropLabel(prop) {
     98    const label = this.props[`${prop}Label`];
     99    let labelValue;
    100 
    101    // If the prop is a number, we round it
    102    if (typeof this.props[prop] === "number") {
    103      // Decimal count used to limit numbers in labels.
    104      const decimals = Math.abs(Math.log10(this.props.step));
    105      labelValue = toFixed(this.props[prop], decimals);
    106    } else {
    107      labelValue = this.props[prop];
    108    }
    109    return label ? labelValue : null;
    110  }
    111 
    112  /**
    113   * Check if the given value is valid according to the constraints of this component.
    114   * Ensure it is a number and that it does not go outside the min/max limits, unless
    115   * allowed by the `allowOverflow` and `allowUnderflow` props.
    116   *
    117   * @param  {number} value
    118   *         Numeric value
    119   * @return {boolean}
    120   *         Whether the value conforms to the components contraints.
    121   */
    122  isValueValid(value) {
    123    const { allowOverflow, allowUnderflow, min, max } = this.props;
    124 
    125    if (typeof value !== "number" || isNaN(value)) {
    126      return false;
    127    }
    128 
    129    // Ensure it does not go below minimum value, unless underflow is allowed.
    130    if (min !== undefined && value < min && !allowUnderflow) {
    131      return false;
    132    }
    133 
    134    // Ensure it does not go over maximum value, unless overflow is allowed.
    135    if (max !== undefined && value > max && !allowOverflow) {
    136      return false;
    137    }
    138 
    139    return true;
    140  }
    141 
    142  /**
    143   * Handler for "blur" events from the range and number input fields.
    144   * Reconciles the value between internal state and props.
    145   * Marks the input as non-interactive so it may update in response to changes in props.
    146   */
    147  onBlur() {
    148    const isValid = this.isValueValid(this.state.value);
    149    let value;
    150 
    151    if (isValid) {
    152      value = this.state.value;
    153    } else if (this.state.value !== null) {
    154      value = Math.max(
    155        this.props.min,
    156        Math.min(this.state.value, this.props.max)
    157      );
    158    } else {
    159      value = this.state.initialValue;
    160    }
    161 
    162    // Avoid updating the value if a keyword value like "normal" is present
    163    if (!this.props.valueLabel) {
    164      this.updateValue(value);
    165    }
    166 
    167    this.toggleInteractiveState(false);
    168  }
    169 
    170  /**
    171   * Handler for "change" events from the range and number input fields. Calls the change
    172   * handler provided with props and updates internal state with the current value.
    173   *
    174   * Number inputs in Firefox can't be trusted to filter out non-digit characters,
    175   * therefore we must implement our own validation.
    176   *
    177   * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1398528
    178   *
    179   * @param {Event} e
    180   *        Change event.
    181   */
    182  onChange(e) {
    183    // Regular expresion to check for floating point or integer numbers. Accept negative
    184    // numbers only if the min value is negative. Otherwise, expect positive numbers.
    185    // Whitespace and non-digit characters are invalid (aside from a single dot).
    186    const regex =
    187      this.props.min && this.props.min < 0
    188        ? /^-?[0-9]+(.[0-9]+)?$/
    189        : /^[0-9]+(.[0-9]+)?$/;
    190    let string = e.target.value.trim();
    191 
    192    // Check if the input does not match
    193    if (e.target.validity.badInput) {
    194      return;
    195    }
    196 
    197    // Prefix with zero if the string starts with a dot: .5 => 0.5
    198    if (string.charAt(0) === "." && string.length > 1) {
    199      string = "0" + string;
    200      e.target.value = string;
    201    }
    202 
    203    // Accept empty strings to allow the input value to be completely erased while typing.
    204    // A null value will be handled on blur. @see this.onBlur()
    205    if (string === "") {
    206      this.setState(prevState => {
    207        return {
    208          ...prevState,
    209          value: null,
    210        };
    211      });
    212 
    213      return;
    214    }
    215 
    216    if (!regex.test(string)) {
    217      return;
    218    }
    219 
    220    const value = parseFloat(string);
    221    this.updateValue(value);
    222  }
    223 
    224  onFocus(e) {
    225    if (e.target.type === "number") {
    226      e.target.select();
    227    }
    228 
    229    this.setState(prevState => {
    230      return {
    231        ...prevState,
    232        interactive: true,
    233        initialValue: this.props.value,
    234      };
    235    });
    236  }
    237 
    238  onUnitChange(e) {
    239    this.props.onChange(
    240      this.props.name,
    241      this.props.value,
    242      this.props.unit,
    243      e.target.value
    244    );
    245    // Reset internal state value and wait for converted value from props.
    246    this.setState(prevState => {
    247      return {
    248        ...prevState,
    249        value: null,
    250      };
    251    });
    252  }
    253 
    254  onMouseDown() {
    255    this.toggleInteractiveState(true);
    256  }
    257 
    258  onMouseUp() {
    259    this.toggleInteractiveState(false);
    260  }
    261 
    262  /**
    263   * Toggle the "interactive" state which causes render() to use `value` fom internal
    264   * state instead of from props to prevent jittering during continous dragging of the
    265   * range input thumb or incrementing from the number input.
    266   *
    267   * @param {boolean} isInteractive
    268   *        Whether to mark the interactive state on or off.
    269   */
    270  toggleInteractiveState(isInteractive) {
    271    this.setState(prevState => {
    272      return {
    273        ...prevState,
    274        interactive: isInteractive,
    275      };
    276    });
    277  }
    278 
    279  /**
    280   * Calls the given `onChange` callback with the current property name, value and unit
    281   * if the value is valid according to the constraints of this component (min, max).
    282   * Updates the internal state with the current value. This will be used to render the
    283   * UI while the input is interactive and the user may be typing a value that's not yet
    284   * valid.
    285   *
    286   * @see this.onBlur() for logic reconciling the internal state with props.
    287   *
    288   * @param {number} value
    289   *        Numeric property value.
    290   */
    291  updateValue(value) {
    292    if (this.isValueValid(value)) {
    293      this.props.onChange(this.props.name, toFixed(value, 3), this.props.unit);
    294    }
    295 
    296    this.setState(prevState => {
    297      return {
    298        ...prevState,
    299        value,
    300      };
    301    });
    302  }
    303 
    304  renderUnitSelect() {
    305    if (!this.props.unitOptions.length) {
    306      return null;
    307    }
    308 
    309    // Ensure the select element has the current unit type even if we don't recognize it.
    310    // The unit conversion function will use a 1-to-1 scale for unrecognized units.
    311    const options = this.props.unitOptions.includes(this.props.unit)
    312      ? this.props.unitOptions
    313      : this.props.unitOptions.concat([this.props.unit]);
    314 
    315    return dom.select(
    316      {
    317        className: "font-value-select",
    318        disabled: this.props.disabled,
    319        onChange: this.onUnitChange,
    320        value: this.props.unit,
    321      },
    322      options.map(unit => {
    323        return dom.option(
    324          {
    325            key: unit,
    326            value: unit,
    327          },
    328          unit
    329        );
    330      })
    331    );
    332  }
    333 
    334  renderLabelContent() {
    335    const { label, name, nameLabel } = this.props;
    336 
    337    const labelEl = dom.span(
    338      {
    339        className: "font-control-label-text",
    340        "aria-describedby": nameLabel ? `detail-${name}` : null,
    341      },
    342      label
    343    );
    344 
    345    // Show the `name` prop value as an additional label if the `nameLabel` prop is true.
    346    const detailEl = nameLabel
    347      ? dom.span(
    348          {
    349            className: "font-control-label-detail",
    350            id: `detail-${name}`,
    351          },
    352          this.getPropLabel("name")
    353        )
    354      : null;
    355 
    356    return createElement(Fragment, null, labelEl, detailEl);
    357  }
    358 
    359  renderValueLabel() {
    360    if (!this.props.valueLabel) {
    361      return null;
    362    }
    363 
    364    return dom.div({ className: "font-value-label" }, this.props.valueLabel);
    365  }
    366 
    367  render() {
    368    // Guard against bad axis data.
    369    if (this.props.min === this.props.max) {
    370      return null;
    371    }
    372 
    373    const propsValue =
    374      this.props.value !== null ? this.props.value : this.props.defaultValue;
    375 
    376    const defaults = {
    377      min: this.props.min,
    378      max: this.props.max,
    379      onBlur: this.onBlur,
    380      onChange: this.onChange,
    381      onFocus: this.onFocus,
    382      step: this.props.step,
    383      // While interacting with the range and number inputs, prevent updating value from
    384      // outside props which is debounced and causes jitter on successive renders.
    385      value: this.state.interactive ? this.state.value : propsValue,
    386    };
    387 
    388    const range = dom.input({
    389      ...defaults,
    390      onMouseDown: this.onMouseDown,
    391      onMouseUp: this.onMouseUp,
    392      className: "font-value-slider",
    393      disabled: this.props.disabled,
    394      name: this.props.name,
    395      title: this.props.label,
    396      type: "range",
    397    });
    398 
    399    const input = dom.input({
    400      ...defaults,
    401      // Remove lower limit from number input if it is allowed to underflow.
    402      min: this.props.allowUnderflow ? null : this.props.min,
    403      // Remove upper limit from number input if it is allowed to overflow.
    404      max: this.props.allowOverflow ? null : this.props.max,
    405      name: this.props.name,
    406      className: "font-value-input",
    407      disabled: this.props.disabled,
    408      type: "number",
    409    });
    410 
    411    return dom.label(
    412      {
    413        className: `font-control ${this.props.className}`,
    414        disabled: this.props.disabled,
    415      },
    416      dom.div(
    417        {
    418          className: "font-control-label",
    419          title: this.props.label,
    420        },
    421        this.renderLabelContent()
    422      ),
    423      dom.div(
    424        {
    425          className: "font-control-input",
    426        },
    427        dom.div(
    428          {
    429            className: "font-value-slider-container",
    430            "data-min": this.getPropLabel("min"),
    431            "data-max": this.getPropLabel("max"),
    432          },
    433          range
    434        ),
    435        this.renderValueLabel(),
    436        this.props.showInput && input,
    437        this.props.showUnit && this.renderUnitSelect()
    438      )
    439    );
    440  }
    441 }
    442 
    443 module.exports = FontPropertyValue;