diff --git a/packages/ui-app/src/Input.tsx b/packages/ui-app/src/Input.tsx index 677ea1f706..f1edb3a541 100644 --- a/packages/ui-app/src/Input.tsx +++ b/packages/ui-app/src/Input.tsx @@ -32,6 +32,8 @@ type Props = BareProps & { onBlur?: (event: React.KeyboardEvent) => void, onKeyDown?: (event: React.KeyboardEvent) => void, onKeyUp?: (event: React.KeyboardEvent) => void, + onKeyPress?: (event: React.KeyboardEvent) => void, + onPaste?: (event: React.ClipboardEvent) => void, placeholder?: string, tabIndex?: number, type?: Input$Type, @@ -43,6 +45,12 @@ type State = { name: string; }; +// Find decimal separator used in current locale +const getDecimalSeparator = (): string => Intl.NumberFormat() + .formatToParts(1.1) + .find(part => part.type === 'decimal')! + .value; + // note: KeyboardEvent.keyCode and KeyboardEvent.which are deprecated const KEYS = { A: 'a', @@ -58,7 +66,8 @@ const KEYS = { TAB: 'Tab', V: 'v', X: 'x', - ZERO: '0' + ZERO: '0', + DECIMAL: getDecimalSeparator() }; const KEYS_PRE: Array = [KEYS.ALT, KEYS.CMD, KEYS.CTRL]; @@ -134,6 +143,7 @@ export default class Input extends React.PureComponent { ? 'new-password' : 'off' } + onPaste={this.onPaste} /> { isEditable @@ -169,6 +179,14 @@ export default class Input extends React.PureComponent { onKeyUp(event); } } + + private onPaste = (event: React.ClipboardEvent): void => { + const { onPaste } = this.props; + + if (onPaste) { + onPaste(event); + } + } } export { diff --git a/packages/ui-app/src/InputNumber.tsx b/packages/ui-app/src/InputNumber.tsx index 83ec5ba25e..490474b609 100644 --- a/packages/ui-app/src/InputNumber.tsx +++ b/packages/ui-app/src/InputNumber.tsx @@ -11,7 +11,7 @@ import { formatBalance, isUndefined } from '@polkadot/util'; import { classes } from './util'; import { BitLengthOption } from './constants'; import Dropdown from './Dropdown'; -import Input, { KEYS, KEYS_PRE, isCopy, isCut, isPaste, isSelectAll } from './Input'; +import Input, { KEYS, KEYS_PRE } from './Input'; import translate from './translate'; type Props = BareProps & I18nProps & { @@ -22,6 +22,7 @@ type Props = BareProps & I18nProps & { isDisabled?: boolean, isError?: boolean, isSi?: boolean, + isDecimal?: boolean, label?: any, maxLength?: number, onChange?: (value?: BN) => void, @@ -31,16 +32,15 @@ type Props = BareProps & I18nProps & { }; type State = { - defaultValue?: string, isPreKeyDown: boolean, isValid: boolean, siOptions: Array<{ value: string, text: string }>, siUnit: string, + value: string, valueBN: BN }; const DEFAULT_BITLENGTH = BitLengthOption.NORMAL_NUMBERS as BitLength; -const KEYS_ALLOWED: Array = [KEYS.ARROW_LEFT, KEYS.ARROW_RIGHT, KEYS.BACKSPACE, KEYS.ENTER, KEYS.ESCAPE, KEYS.TAB]; class InputNumber extends React.PureComponent { constructor (props: Props) { @@ -48,10 +48,10 @@ class InputNumber extends React.PureComponent { const { defaultValue, isSi, value } = this.props; let valueBN = new BN(value || 0); - const si = formatBalance.calcSi(valueBN.toString()); + const si = formatBalance.findSi('-'); this.state = { - defaultValue: isSi + value: isSi ? new BN(defaultValue || valueBN).div(new BN(10).pow(new BN(si.power))).toString() : (defaultValue || valueBN).toString(), isPreKeyDown: false, @@ -72,32 +72,26 @@ class InputNumber extends React.PureComponent { InputNumber.units = units; } - static getDerivedStateFromProps ({ isDisabled, isSi, defaultValue = '0' }: Props, state: State): Partial | null { + static getDerivedStateFromProps ({ isDisabled, isSi, defaultValue = '0' }: Props): Partial | null { if (!isDisabled || !isSi) { return null; } return { - defaultValue: formatBalance(defaultValue, false), + value: formatBalance(defaultValue, false), siUnit: formatBalance.calcSi(defaultValue.toString(), formatBalance.getDefaults().decimals).value }; } render () { - const { bitLength = DEFAULT_BITLENGTH, className, defaultValue = '0', help, isSi, isDisabled, maxLength, style, t } = this.props; - const { isValid } = this.state; + const { bitLength = DEFAULT_BITLENGTH, className, help, isSi, isDisabled, maxLength, style, t } = this.props; + const { isValid, value } = this.state; const maxValueLength = this.maxValue(bitLength).toString().length - 1; - const value = this.state.defaultValue || defaultValue; return ( { onChange={this.onChange} onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} + onPaste={this.onPaste} placeholder={t('Positive number')} style={style} - value={ - isDisabled - ? value - : undefined - } + value={value} type='text' > - {this.renderSiDropdown()} + {isSi && this.renderSiDropdown()} ); } private renderSiDropdown () { - const { isSi } = this.props; const { siOptions, siUnit } = this.state; - if (!isSi) { - return undefined; - } - return ( { return value.bitLength() <= (bitLength || DEFAULT_BITLENGTH); } - private isValidKey = (event: React.KeyboardEvent, isPreKeyDown: boolean): boolean => { - const { value: previousValue } = event.target as HTMLInputElement; - // prevents entry of zero if initial digit is zero - const isDuplicateZero = previousValue[0] === '0' && event.key === KEYS.ZERO; - - if (isDuplicateZero) { - return false; - } - - // allow cut/copy/paste combinations but not non-numeric letters (i.e. a, c, x, v) individually - if ( - (isSelectAll(event.key, isPreKeyDown)) || - (isCut(event.key, isPreKeyDown)) || - (isCopy(event.key, isPreKeyDown)) || - (isPaste(event.key, isPreKeyDown)) - ) { - return true; - } - - if (isNaN(Number(event.key)) && !KEYS_ALLOWED.includes(event.key)) { + private isValidNumber (input: BN, bitLength: number = DEFAULT_BITLENGTH): boolean { + const maxBN = this.maxValue(bitLength); + if (input.lt(new BN(0)) || !input.lt(maxBN) || !this.isValidBitLength(input, bitLength)) { return false; } return true; } - private isValidNumber (input: BN, bitLength: number = DEFAULT_BITLENGTH): boolean { - const maxBN = this.maxValue(bitLength); - - if (!input.lt(maxBN) || !this.isValidBitLength(input, bitLength)) { - return false; - } - - return true; + private regex = (): RegExp => { + const { isDecimal, isSi } = this.props; + return new RegExp( + (isSi || isDecimal) ? + `^(0|[1-9]\\d*)(\\${KEYS.DECIMAL}\\d*)?$` : + `^(0|[1-9]\\d*)$` + ); } private onChange = (value: string): void => { @@ -187,10 +155,10 @@ class InputNumber extends React.PureComponent { const { siUnit } = this.state; try { - const valueBN = this.applySi(siUnit, new BN(value || 0)); + const valueBN = this.inputValueToBn(value, siUnit); const isValid = this.isValidNumber(valueBN, bitLength); - this.setState({ isValid, valueBN }); + this.setState({ isValid, value, valueBN }); onChange && onChange( isValid @@ -207,79 +175,110 @@ class InputNumber extends React.PureComponent { if (KEYS_PRE.includes(event.key)) { this.setState({ isPreKeyDown: true }); + return; } - // restrict input of certain keys - const isValid = this.isValidKey(event, isPreKeyDown); + if (event.key.length === 1 && !isPreKeyDown) { + const { selectionStart: i, selectionEnd: j, value } = event.target as HTMLInputElement; + const newValue = `${ + value.substring(0, i!) + }${ + event.key + }${ + value.substring(j!) + }`; - if (!isValid) { - event.preventDefault(); + if (!this.regex().test(newValue)) { + event.preventDefault(); + } } } private onKeyUp = (event: React.KeyboardEvent): void => { - const { value: newValue } = event.target as HTMLInputElement; - const isNewValueZero = new BN(newValue).isZero(); - if (KEYS_PRE.includes(event.key)) { this.setState({ isPreKeyDown: false }); } - - /* if new value equates to '0' in BN when but it's length is >=1 (i.e. '012', '00', etc) - * then replace the input value with just '0'. - * otherwise remove the preceding zeros from the new value (i.e. '0123' -> '123') - * note: edge case glitch occurs if existing value is '0' and you 'hold down' and keep - * pasting a value of '00' after it, then sometimes when you let go the - * remaining value shown as '000' or '00000' in the UI, but it's still ok because - * the actual BN if the user submitted would still be '0', and if they then press any key - * the UI input value resets to '0' - */ - if (isNewValueZero && newValue.length >= 1) { - (event.target as HTMLInputElement).value = '0'; - } else { - (event.target as HTMLInputElement).value = newValue.replace(/^0+/, ''); - } } - private applySi (siUnit: string, value: BN): BN { - const { isSi } = this.props; + private onPaste = (event: React.ClipboardEvent): void => { + const { value: newValue } = event.target as HTMLInputElement; - if (!isSi) { - return value; + if (!this.regex().test(newValue)) { + event.preventDefault(); + return; } - - const si = formatBalance.findSi(siUnit); - const power = new BN(formatBalance.getDefaults().decimals + si.power); - - return value.mul(new BN(10).pow(power)); - } - - private applyNewSi (oldSi: string, newSi: string, value: BN): BN { - const si = formatBalance.findSi(oldSi); - const power = new BN(formatBalance.getDefaults().decimals + si.power); - - return this.applySi(newSi, value.div(new BN(10).pow(power))); } private selectSiUnit = (siUnit: string): void => { this.setState((prevState: State) => { const { bitLength, onChange } = this.props; - const valueBN = this.applyNewSi(prevState.siUnit, siUnit, prevState.valueBN); - const isValid = this.isValidNumber(valueBN, bitLength); + const isValid = this.isValidNumber(prevState.valueBN, bitLength); + const value = this.bnToInputValue(prevState.valueBN, siUnit); onChange && onChange( isValid - ? valueBN + ? prevState.valueBN : undefined ); return { isValid, siUnit, - valueBN + value }; }); } + + private inputValueToBn = (value: string, siUnit: string): BN => { + const { isSi } = this.props; + const basePower = isSi ? formatBalance.getDefaults().decimals : 0; + const siPower = isSi ? formatBalance.findSi(siUnit).power : 0; + + const isDecimalValue = value.match(/^(\d+)\.(\d+)$/); + + if (isDecimalValue) { + if (siPower - isDecimalValue[2].length < -basePower) { + return new BN(-1); + } + + const div = new BN(value.replace(/\.\d*$/, '')); + const mod = new BN(value.replace(/^\d+\./, '')); + + return div + .mul(new BN(10).pow(new BN(basePower + siPower))) + .add(mod.mul(new BN(10).pow((new BN(basePower + siPower - mod.toString().length))))); + } else { + return new BN(value.replace(/[^\d]/g, '')) + .mul(new BN(10).pow(new BN(basePower + siPower))); + } + } + + private bnToInputValue = (bn: BN, siUnit: string): string => { + const { isSi } = this.props; + + const basePower = isSi ? formatBalance.getDefaults().decimals : 0; + const siPower = isSi ? formatBalance.findSi(siUnit).power : 0; + + const base = new BN(10).pow(new BN(basePower + siPower)); + const zero = new BN(0); + const div = bn.div(base); + const mod = bn.mod(base); + + return `${ + div.gt(zero) ? div.toString() : '0' + }${ + mod.gt(zero) ? + (() => { + const padding = Math.max( + mod.toString().length, + base.toString().length - div.toString().length, + bn.toString().length - div.toString().length + ); + return `.${mod.toString(10, padding).replace(/0*$/, '')}`; + })() : + '' + }`; + } } export {