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;