diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..63187fe --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +max_line_length = 80 +trim_trailing_whitespace = true + +[*.md] +max_line_length = 0 +trim_trailing_whitespace = false diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..5ac541a --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Example Contributing Guidelines + +This is an example of GitHub's contributing guidelines file. Check out GitHub's [CONTRIBUTING.md help center article](https://help.github.com/articles/setting-guidelines-for-repository-contributors/) for more information. diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..3b7c28f --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,11 @@ +* **I'm submitting a ...** +[ ] bug report +[ ] feature request +[ ] question about the decisions made in the repository +[ ] question about how to use this project + +* **Summary** + + + +* **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. StackOverflow, personal fork, etc.) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..caae6b1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +* **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) + + + +* **What is the current behavior?** (You can also link to an open issue here) + + + +* **What is the new behavior (if this is a feature change)?** + + + +* **Other information**: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93e10ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +build +test +src/**.js +.idea/* + +coverage +.nyc_output +*.log + +package-lock.json \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..845f94c --- /dev/null +++ b/.npmignore @@ -0,0 +1,14 @@ +src +test +tsconfig.json +tsconfig.module.json +tslint.json +.travis.yml +.github +.prettierignore +.vscode +build/docs +**/*.spec.* +coverage +.nyc_output +*.log diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0e80a3c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# package.json is formatted by package managers, so we ignore it here +package.json \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c7b77d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 antoniopresto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..95507e2 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# antd-masked-input + +Ant Design Mask Input diff --git a/package.json b/package.json new file mode 100644 index 0000000..08aab93 --- /dev/null +++ b/package.json @@ -0,0 +1,92 @@ +{ + "name": "antd-masked-input", + "version": "0.0.1", + "description": "Ant Design Mask Input", + "main": "build/main/index.js", + "typings": "build/main/index.d.ts", + "module": "build/module/index.js", + "repository": "https://github.com/antoniopresto/antd-masked-input", + "license": "MIT", + "keywords": [], + "scripts": { + "describe": "npm-scripts-info", + "build": "run-s clean && run-p build:*", + "build:main": "tsc -p tsconfig.json", + "build:module": "tsc -p tsconfig.module.json", + "fix": "run-s fix:*", + "test": "run-s build test:*", + "test:unit": "nyc --silent ava", + "watch": "run-s clean build:main && run-p \"build:main -- -w\" \"test:unit -- --watch\"", + "cov": "run-s build test:unit cov:html && opn coverage/index.html", + "cov:html": "nyc report --reporter=html", + "cov:send": "nyc report --reporter=lcov > coverage.lcov && codecov", + "cov:check": "nyc report && nyc check-coverage --lines 100 --functions 100 --branches 100", + "doc": "run-s doc:html && opn build/docs/index.html", + "doc:html": "typedoc src/ --exclude **/*.spec.ts --target ES6 --mode file --out build/docs", + "doc:json": "typedoc src/ --exclude **/*.spec.ts --target ES6 --mode file --json build/docs/typedoc.json", + "doc:publish": "gh-pages -m \"[ci skip] Updates\" -d build/docs", + "version": "standard-version", + "reset": "git clean -dfx && git reset --hard && npm i", + "clean": "trash build test", + "all": "run-s reset test cov:check doc:html", + "prepare-release": "run-s all version doc:publish" + }, + "scripts-info": { + "info": "Display information about the package scripts", + "build": "Clean and rebuild the project", + "test": "unit test the project", + "watch": "Watch and rebuild the project on save, then rerun relevant tests", + "cov": "Rebuild, run tests, then create and open the coverage report", + "doc": "Generate HTML API documentation and open it in a browser", + "doc:json": "Generate API documentation in typedoc JSON format", + "version": "Bump package.json version, update CHANGELOG.md, tag release", + "reset": "Delete all untracked files and reset the repo to the last commit", + "prepare-release": "One-step: clean, build, test, publish docs, and prep a release" + }, + "engines": { + "node": ">=8.9" + }, + "peerDependencies": { + "antd": ">=3.0.0" + }, + "dependencies": { + "antd": "^3.17.0", + "react": "^16.8.6" + }, + "devDependencies": { + "ava": "1.0.0-beta.7", + "codecov": "^3.1.0", + "cz-conventional-changelog": "^2.1.0", + "gh-pages": "^2.0.1", + "npm-run-all": "^4.1.5", + "nyc": "^13.1.0", + "opn-cli": "^4.0.0", + "prettier": "^1.15.2", + "standard-version": "^4.4.0", + "trash-cli": "^1.4.0", + "typedoc": "^0.13.0", + "typescript": "^3.1.6" + }, + "ava": { + "failFast": true, + "files": [ + "build/main/**/*.spec.js" + ], + "sources": [ + "build/main/**/*.js" + ] + }, + "config": { + "commitizen": { + "path": "cz-conventional-changelog" + } + }, + "prettier": { + "singleQuote": true + }, + "nyc": { + "exclude": [ + "**/*.spec.js" + ] + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..19ae0b7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export * from './lib/MaskedInput'; diff --git a/src/lib/MaskedInput.spec.ts b/src/lib/MaskedInput.spec.ts new file mode 100644 index 0000000..458674e --- /dev/null +++ b/src/lib/MaskedInput.spec.ts @@ -0,0 +1,7 @@ +// tslint:disable:no-expression-statement +import test from 'ava'; + +// TODO +test('one', t => { + t.is(1, 1); +}); diff --git a/src/lib/MaskedInput.tsx b/src/lib/MaskedInput.tsx new file mode 100644 index 0000000..7fd6f56 --- /dev/null +++ b/src/lib/MaskedInput.tsx @@ -0,0 +1,245 @@ +import React, { ChangeEvent, Component, ClipboardEvent } from 'react'; +import { Input } from 'antd'; +import InputMask from './inputmask-core'; +import { isRedo, isUndo, getSelection, setSelection } from './helpers'; +import { InputProps } from 'antd/lib/input'; + +type Props = InputProps & { + mask: string; + formatCharacters?: object; + placeholderChar?: string; + value?: string; +}; + +type TChangeEvent = ChangeEvent; +type TKeyboardEvent = any; +type TClipboardEvent = ClipboardEvent; + +class FormInputComponent extends Component { + mask: InputMask; + input!: HTMLInputElement; + + constructor(props: Props) { + super(props); + + let options: any = { + pattern: this.props.mask, + value: this.props.value, + formatCharacters: this.props.formatCharacters, + }; + + if (this.props.placeholderChar) { + options.placeholderChar = this.props.placeholderChar; + } + + this.mask = new InputMask(options); + } + + static defaultProps = { + value: '', + rules: [], + }; + + componentWillReceiveProps(nextProps: Props) { + if (!this.props.mask) return null; + if (this.props.mask !== nextProps.mask && this.props.value !== nextProps.mask) { + // if we get a new value and a new mask at the same time + // check if the mask.value is still the initial value + // - if so use the nextProps value + // - otherwise the `this.mask` has a value for us (most likely from paste action) + if (this.mask.getValue() === this.mask.emptyValue) { + this.mask.setPattern(nextProps.mask, { value: nextProps.value }); + } else { + this.mask.setPattern(nextProps.mask, { value: this.mask.getRawValue() }); + } + } else if (this.props.mask !== nextProps.mask) { + this.mask.setPattern(nextProps.mask, { value: this.mask.getRawValue() }); + } else if (this.props.value !== nextProps.value) { + this.mask.setValue(nextProps.value); + } + return; + } + + componentWillUpdate(nextProps: Props) { + if (!this.props.mask) return null; + if (nextProps.mask !== this.props.mask) { + this._updatePattern(nextProps); + } + return; + } + + componentDidUpdate(prevProps: Props) { + if (!this.props.mask) return null; + if (prevProps.mask !== this.props.mask && this.mask.selection.start) { + this._updateInputSelection(); + } + return; + } + + _updatePattern(props: Props) { + this.mask.setPattern(props.mask, { + value: this.mask.getRawValue(), + selection: getSelection(this.input), + }); + } + + _updateMaskSelection() { + this.mask.selection = getSelection(this.input); + } + + _updateInputSelection() { + setSelection(this.input, this.mask.selection); + } + + _onChange = (e: TChangeEvent) => { + // console.log('onChange', JSON.stringify(getSelection(this.input)), e.target.value) + + let maskValue = this.mask.getValue(); + let incomingValue = e.target.value; + if (incomingValue !== maskValue) { + // only modify mask if form contents actually changed + this._updateMaskSelection(); + this.mask.setValue(incomingValue); // write the whole updated value into the mask + e.target.value = this._getDisplayValue(); // update the form with pattern applied to the value + this._updateInputSelection(); + } + + if (this.props.onChange) { + this.props.onChange(e); + } + }; + + _onKeyDown = (e: TKeyboardEvent) => { + setTimeout(() => { + this.input.classList[this.input.value ? 'add' : 'remove']('has-value'); + }, 100); + + if (isUndo(e)) { + e.preventDefault(); + if (this.mask.undo()) { + e.target.value = this._getDisplayValue(); + this._updateInputSelection(); + if (this.props.onChange) { + this.props.onChange(e); + } + } + return; + } else if (isRedo(e)) { + e.preventDefault(); + if (this.mask.redo()) { + e.target.value = this._getDisplayValue(); + this._updateInputSelection(); + if (this.props.onChange) { + this.props.onChange(e); + } + } + return; + } + + if (e.key === 'Backspace') { + e.preventDefault(); + this._updateMaskSelection(); + if (this.mask.backspace()) { + let value = this._getDisplayValue(); + e.target.value = value; + if (value) { + this._updateInputSelection(); + } + if (this.props.onChange) { + this.props.onChange(e); + } + } + } + }; + + _onKeyPress = (e: TKeyboardEvent) => { + // console.log('onKeyPress', JSON.stringify(getSelection(this.input)), e.key, e.target.value) + + // Ignore modified key presses + // Ignore enter key to allow form submission + if (e.metaKey || e.altKey || e.ctrlKey || e.key === 'Enter') { + return; + } + + e.preventDefault(); + this._updateMaskSelection(); + if (this.mask.input(e.key || e.data)) { + e.target.value = this.mask.getValue(); + this._updateInputSelection(); + if (this.props.onChange) { + this.props.onChange(e); + } + } + }; + + _onPaste = (e: TClipboardEvent) => { + e.preventDefault(); + this._updateMaskSelection(); + // getData value needed for IE also works in FF & Chrome + if (this.mask.paste(e.clipboardData.getData('Text'))) { + // @ts-ignore + e.target.value = this.mask.getValue(); + // Timeout needed for IE + setTimeout(() => this._updateInputSelection(), 0); + if (this.props.onChange) { + // @ts-ignore + this.props.onChange(e); + } + } + }; + + _getDisplayValue() { + let value = this.mask.getValue(); + return value === this.mask.emptyValue ? '' : value; + } + + _keyPressPropName() { + if (typeof navigator !== 'undefined') { + return navigator.userAgent.match(/Android/i) ? 'onBeforeInput' : 'onKeyPress'; + } + return 'onKeyPress'; + } + + _getEventHandlers() { + return { + onChange: this._onChange, + onKeyDown: this._onKeyDown, + onPaste: this._onPaste, + [this._keyPressPropName()]: this._onKeyPress, + }; + } + + focus() { + this.input.focus(); + } + + blur() { + this.input.blur(); + } + + getInputProps = () => { + let maxLength = this.mask.pattern.length; + let eventHandlers = this._getEventHandlers(); + let { placeholder = this.mask.emptyValue } = this.props; + + let { placeholderChar, formatCharacters, ...cleanedProps } = this.props; + const props = { ...cleanedProps, ...eventHandlers, maxLength, placeholder }; + delete props.value; + return props; + }; + + render() { + return ( + { + if (r) { + this.input = r.input; + } + }} + /> + ); + } +} + +export const MaskedInput = FormInputComponent; diff --git a/src/lib/Pattern.ts b/src/lib/Pattern.ts new file mode 100644 index 0000000..e7f278a --- /dev/null +++ b/src/lib/Pattern.ts @@ -0,0 +1,127 @@ +import { + DEFAULT_FORMAT_CHARACTERS, + DEFAULT_PLACEHOLDER_CHAR, + ESCAPE_CHAR, + FormatCharacters, +} from './helpers'; + +export class Pattern { + placeholderChar: string; + formatCharacters: FormatCharacters; + source: string; + + /** Pattern characters after escape characters have been processed. */ + pattern = []; + + /** Length of the pattern after escape characters have been processed. */ + length = 0; + + /** Index of the first editable character. */ + firstEditableIndex: null | number = null; + + /** Index of the last editable character. */ + lastEditableIndex: null | number = null; + + /** Lookup for indices of editable characters in the pattern. */ + _editableIndices: { [key: number]: boolean } = {}; + + /** If true, only the pattern before the last valid value character shows. */ + isRevealingMask: boolean; + + constructor( + source: string, + formatCharacters: FormatCharacters, + placeholderChar: string, + isRevealingMask = false, + ) { + /** Placeholder character */ + this.placeholderChar = placeholderChar || DEFAULT_PLACEHOLDER_CHAR; + /** Format character definitions. */ + this.formatCharacters = formatCharacters || DEFAULT_FORMAT_CHARACTERS; + /** Pattern definition string with escape characters. */ + this.source = source; + + this.isRevealingMask = isRevealingMask; + + this._parse(); + } + + _parse() { + var sourceChars = this.source.split(''); + var patternIndex = 0; + var pattern: string[] = []; + + for (var i = 0, l = sourceChars.length; i < l; i++) { + var char = sourceChars[i]; + if (char === ESCAPE_CHAR) { + if (i === l - 1) { + throw new Error('InputMask: pattern ends with a raw ' + ESCAPE_CHAR); + } + char = sourceChars[++i]; + } else if (char in this.formatCharacters) { + if (this.firstEditableIndex === null) { + this.firstEditableIndex = patternIndex; + } + this.lastEditableIndex = patternIndex; + this._editableIndices[patternIndex] = true; + } + + pattern.push(char); + patternIndex++; + } + + if (this.firstEditableIndex === null) { + throw new Error( + 'InputMask: pattern "' + this.source + '" does not contain any editable characters.', + ); + } + + // @ts-ignore + this.pattern = pattern; + this.length = pattern.length; + } + + formatValue(value: string[]): string[] { + var valueBuffer = new Array(this.length); + var valueIndex = 0; + + for (var i = 0, l = this.length; i < l; i++) { + if (this.isEditableIndex(i)) { + if ( + this.isRevealingMask && + value.length <= valueIndex && + !this.isValidAtIndex(value[valueIndex], i) + ) { + break; + } + valueBuffer[i] = + value.length > valueIndex && this.isValidAtIndex(value[valueIndex], i) + ? this.transform(value[valueIndex], i) + : this.placeholderChar; + valueIndex++; + } else { + valueBuffer[i] = this.pattern[i]; + // Also allow the value to contain static values from the pattern by + // advancing its index. + if (value.length > valueIndex && value[valueIndex] === this.pattern[i]) { + valueIndex++; + } + } + } + + return valueBuffer; + } + + isEditableIndex(index: number) { + return !!this._editableIndices[index]; + } + + isValidAtIndex(char: string, index: number) { + return this.formatCharacters[this.pattern[index]].validate(char); + } + + transform(char: string, index: number) { + var format = this.formatCharacters[this.pattern[index]]; + return typeof format.transform == 'function' ? format.transform(char) : char; + } +} diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts new file mode 100644 index 0000000..fa29eda --- /dev/null +++ b/src/lib/helpers.ts @@ -0,0 +1,127 @@ +let KEYCODE_Z = 90; +let KEYCODE_Y = 89; + +export function isUndo(e: KeyboardEvent): boolean { + return (e.ctrlKey || e.metaKey) && e.keyCode === (e.shiftKey ? KEYCODE_Y : KEYCODE_Z); +} + +export function isRedo(e: KeyboardEvent): boolean { + return (e.ctrlKey || e.metaKey) && e.keyCode === (e.shiftKey ? KEYCODE_Z : KEYCODE_Y); +} + +type GetSelectionResult = { start: number; end: number }; + +export function getSelection(el: HTMLInputElement): GetSelectionResult { + let start, end; + if (el.selectionStart !== undefined) { + start = el.selectionStart; + end = el.selectionEnd; + } else { + try { + el.focus(); + // @ts-ignore (IE only) + let rangeEl = el.createTextRange(); + let clone = rangeEl.duplicate(); + + // @ts-ignore (IE only) + rangeEl.moveToBookmark(document.selection.createRange().getBookmark()); + clone.setEndPoint('EndToStart', rangeEl); + + start = clone.text.length; + end = start + rangeEl.text.length; + } catch (e) { + /* not focused or not visible */ + } + } + + return { start, end }; +} + +export function setSelection(el: HTMLInputElement, selection: GetSelectionResult) { + try { + if (el.selectionStart !== undefined) { + el.focus(); + el.setSelectionRange(selection.start, selection.end); + } else { + el.focus(); + // @ts-ignore (IE only) + let rangeEl = el.createTextRange(); + rangeEl.collapse(true); + rangeEl.moveStart('character', selection.start); + rangeEl.moveEnd('character', selection.end - selection.start); + rangeEl.select(); + } + } catch (e) { + /* not focused or not visible */ + } +} + +/** + * Merge an object defining format characters into the defaults. + * Passing null/undefined for en existing format character removes it. + * Passing a definition for an existing format character overrides it. + */ +export function mergeFormatCharacters(formatCharacters: FormatCharacters) { + var merged = { ...DEFAULT_FORMAT_CHARACTERS }; + if (formatCharacters) { + var chars = Object.keys(formatCharacters); + for (var i = 0, l = chars.length; i < l; i++) { + var char = chars[i]; + if (formatCharacters[char] == null) { + delete merged[char]; + } else { + merged[char] = formatCharacters[char]; + } + } + } + return merged; +} + +export const ESCAPE_CHAR = '\\'; + +export const DIGIT_RE = /^\d$/; +export const LETTER_RE = /^[A-Za-z]$/; +export const ALPHANNUMERIC_RE = /^[\dA-Za-z]$/; + +export const DEFAULT_PLACEHOLDER_CHAR = '_'; + +export const DEFAULT_FORMAT_CHARACTERS: FormatCharacters = { + '*': { + validate: function(char: string) { + return ALPHANNUMERIC_RE.test(char); + }, + }, + '1': { + validate: function(char: string) { + return DIGIT_RE.test(char); + }, + }, + a: { + validate: function(char: string) { + return LETTER_RE.test(char); + }, + }, + A: { + validate: function(char: string) { + return LETTER_RE.test(char); + }, + transform: function(char: string) { + return char.toUpperCase(); + }, + }, + '#': { + validate: function(char: string) { + return ALPHANNUMERIC_RE.test(char); + }, + transform: function(char: string) { + return char.toUpperCase(); + }, + }, +}; + +export type FormatCharacters = { + [key: string]: { + transform?(str: string): string; + validate(str: string): boolean; + }; +}; diff --git a/src/lib/inputmask-core.ts b/src/lib/inputmask-core.ts new file mode 100644 index 0000000..38ec792 --- /dev/null +++ b/src/lib/inputmask-core.ts @@ -0,0 +1,406 @@ +import { DEFAULT_PLACEHOLDER_CHAR, FormatCharacters, mergeFormatCharacters } from './helpers'; +import { Pattern } from './Pattern'; + +type SelectionObject = { start: number; end: number }; + +type Options = { + formatCharacters: FormatCharacters; + pattern: string; + isRevealingMask: boolean; + placeholderChar: string; + selection: SelectionObject; + value: string; +}; + +export class InputMask { + static Pattern = Pattern; + + formatCharacters!: FormatCharacters; + pattern!: Pattern; + isRevealingMask!: boolean; + placeholderChar!: string; + selection!: SelectionObject; + value!: string[]; + emptyValue = ''; + + _history: { + value: string; + selection: SelectionObject; + lastOp: string | null; + startUndo?: boolean; + }[] = []; + _historyIndex: null | number = null; + _lastOp: null | string = null; + _lastSelection: null | SelectionObject = null; + + constructor(options: Partial) { + const mergedOptions: Options = { + ...{ + isRevealingMask: false, + placeholderChar: DEFAULT_PLACEHOLDER_CHAR, + selection: { start: 0, end: 0 }, + value: '', + }, + ...options, + } as Options; + + if (!mergedOptions.pattern) { + throw new Error('InputMask: you must provide a pattern.'); + } + + if ( + typeof mergedOptions.placeholderChar !== 'string' || + mergedOptions.placeholderChar.length > 1 + ) { + throw new Error( + 'InputMask: placeholderChar should be a single character or an empty string.', + ); + } + + this.placeholderChar = mergedOptions.placeholderChar; + this.formatCharacters = mergeFormatCharacters(mergedOptions.formatCharacters); + + this.setPattern(mergedOptions.pattern, { + value: mergedOptions.value, + selection: mergedOptions.selection, + isRevealingMask: mergedOptions.isRevealingMask, + }); + } + + setPattern(patternSource: string, options: Partial) { + const merged = { + selection: { start: 0, end: 0 }, + value: '', + ...options, + }; + + this.pattern = new Pattern( + patternSource, + this.formatCharacters, + this.placeholderChar, + merged.isRevealingMask, + ); + + this.setValue(merged.value); + + this.emptyValue = this.pattern.formatValue([]).join(''); + this.selection = merged.selection; + this._resetHistory(); + } + + setValue(value?: string) { + if (value == null) { + value = ''; + } + this.value = this.pattern.formatValue(value.split('')); + } + + _resetHistory() { + this._history = []; + this._historyIndex = null; + this._lastOp = null; + this._lastSelection = { ...this.selection }; + } + + getValue(): string { + if (this.pattern.isRevealingMask) { + this.value = this.pattern.formatValue(this.getRawValue().split('')); + } + return this.value.join(''); + } + + getRawValue(): string { + var rawValue = []; + for (var i = 0; i < this.value.length; i++) { + if (this.pattern._editableIndices[i] === true) { + rawValue.push(this.value[i]); + } + } + return rawValue.join(''); + } + + /** + * Applies a single character of input based on the current selection. + * @param {string} char + * @return {boolean} true if a change has been made to value or selection as a + * result of the input, false otherwise. + */ + input(char: string) { + // Ignore additional input if the cursor's at the end of the pattern + if ( + this.selection.start === this.selection.end && + this.selection.start === this.pattern.length + ) { + return false; + } + + var selectionBefore = { ...this.selection } as SelectionObject; + var valueBefore = this.getValue(); + + var inputIndex = this.selection.start; + + // If the cursor or selection is prior to the first editable character, make + // sure any input given is applied to it. + if (inputIndex < this.pattern.firstEditableIndex!) { + inputIndex = this.pattern.firstEditableIndex!; + } + + // Bail out or add the character to input + if (this.pattern.isEditableIndex(inputIndex)) { + if (!this.pattern.isValidAtIndex(char, inputIndex)) { + return false; + } + this.value[inputIndex] = this.pattern.transform(char, inputIndex); + } + + // If multiple characters were selected, blank the remainder out based on the + // pattern. + var end = this.selection.end - 1; + while (end > inputIndex) { + if (this.pattern.isEditableIndex(end)) { + this.value[end] = this.placeholderChar; + } + end--; + } + + // Advance the cursor to the next character + this.selection.start = this.selection.end = inputIndex + 1; + + // Skip over any subsequent static characters + while ( + this.pattern.length > this.selection.start && + !this.pattern.isEditableIndex(this.selection.start) + ) { + this.selection.start++; + this.selection.end++; + } + + // History + if (this._historyIndex != null) { + // Took more input after undoing, so blow any subsequent history away + this._history.splice(this._historyIndex, this._history.length - this._historyIndex); + this._historyIndex = null; + } + if ( + this._lastOp !== 'input' || + selectionBefore.start !== selectionBefore.end || + (this._lastSelection !== null && selectionBefore.start !== this._lastSelection.start) + ) { + this._history.push({ value: valueBefore, selection: selectionBefore, lastOp: this._lastOp }); + } + this._lastOp = 'input'; + this._lastSelection = { ...this.selection }; + + return true; + } + + /** + * Attempts to delete from the value based on the current cursor position or + * selection. + * @return {boolean} true if the value or selection changed as the result of + * backspacing, false otherwise. + */ + backspace() { + // If the cursor is at the start there's nothing to do + if (this.selection.start === 0 && this.selection.end === 0) { + return false; + } + + var selectionBefore = { ...this.selection }; + var valueBefore = this.getValue(); + + // No range selected - work on the character preceding the cursor + if (this.selection.start === this.selection.end) { + if (this.pattern.isEditableIndex(this.selection.start - 1)) { + if (this.pattern.isRevealingMask) { + this.value.splice(this.selection.start - 1); + } else { + this.value[this.selection.start - 1] = this.placeholderChar; + } + } + this.selection.start--; + this.selection.end--; + } + // Range selected - delete characters and leave the cursor at the start of the selection + else { + var end = this.selection.end - 1; + while (end >= this.selection.start) { + if (this.pattern.isEditableIndex(end)) { + this.value[end] = this.placeholderChar; + } + end--; + } + this.selection.end = this.selection.start; + } + + // History + if (this._historyIndex != null) { + // Took more input after undoing, so blow any subsequent history away + this._history.splice(this._historyIndex, this._history.length - this._historyIndex); + } + if ( + this._lastOp !== 'backspace' || + selectionBefore.start !== selectionBefore.end || + (this._lastSelection !== null && selectionBefore.start !== this._lastSelection.start) + ) { + this._history.push({ value: valueBefore, selection: selectionBefore, lastOp: this._lastOp }); + } + this._lastOp = 'backspace'; + this._lastSelection = { ...this.selection }; + + return true; + } + + /** + * Attempts to paste a string of input at the current cursor position or over + * the top of the current selection. + * Invalid content at any position will cause the paste to be rejected, and it + * may contain static parts of the mask's pattern. + * @param {string} input + * @return {boolean} true if the paste was successful, false otherwise. + */ + paste(input: string) { + // This is necessary because we're just calling input() with each character + // and rolling back if any were invalid, rather than checking up-front. + var initialState = { + value: this.value.slice(), + selection: { ...this.selection }, + _lastOp: this._lastOp, + _history: this._history.slice(), + _historyIndex: this._historyIndex, + _lastSelection: { ...this._lastSelection }, + }; + + // If there are static characters at the start of the pattern and the cursor + // or selection is within them, the static characters must match for a valid + // paste. + if (this.selection.start < this.pattern.firstEditableIndex!) { + for (var i = 0, l = this.pattern.firstEditableIndex! - this.selection.start; i < l; i++) { + if (input.charAt(i) !== this.pattern.pattern[i]) { + return false; + } + } + + // Continue as if the selection and input started from the editable part of + // the pattern. + input = input.substring(this.pattern.firstEditableIndex! - this.selection.start); + this.selection.start = this.pattern.firstEditableIndex!; + } + + for ( + i = 0, l = input.length; + i < l && this.selection.start <= this.pattern.lastEditableIndex!; + i++ + ) { + var valid = this.input(input.charAt(i)); + // Allow static parts of the pattern to appear in pasted input - they will + // already have been stepped over by input(), so verify that the value + // deemed invalid by input() was the expected static character. + if (!valid) { + if (this.selection.start > 0) { + // XXX This only allows for one static character to be skipped + var patternIndex = this.selection.start - 1; + if ( + !this.pattern.isEditableIndex(patternIndex) && + input.charAt(i) === this.pattern.pattern[patternIndex] + ) { + continue; + } + } + + Object.keys(initialState).forEach(key => { + // @ts-ignore + this[key] = initialState[key]; + }); + + return false; + } + } + + return true; + } + + undo() { + // If there is no history, or nothing more on the history stack, we can't undo + if (this._history.length === 0 || this._historyIndex === 0) { + return false; + } + + var historyItem; + if (this._historyIndex == null) { + // Not currently undoing, set up the initial history index + this._historyIndex = this._history.length - 1; + historyItem = this._history[this._historyIndex]; + // Add a new history entry if anything has changed since the last one, so we + // can redo back to the initial state we started undoing from. + var value = this.getValue(); + if ( + historyItem.value !== value || + historyItem.selection.start !== this.selection.start || + historyItem.selection.end !== this.selection.end + ) { + this._history.push({ + value: value, + selection: { ...this.selection }, + lastOp: this._lastOp, + startUndo: true, + }); + } + } else { + historyItem = this._history[--this._historyIndex]; + } + + this.value = historyItem.value.split(''); + this.selection = historyItem.selection; + this._lastOp = historyItem.lastOp; + return true; + } + + redo() { + if (this._history.length === 0 || this._historyIndex == null) { + return false; + } + var historyItem = this._history[++this._historyIndex]; + // If this is the last history item, we're done redoing + if (this._historyIndex === this._history.length - 1) { + this._historyIndex = null; + // If the last history item was only added to start undoing, remove it + if (historyItem.startUndo) { + this._history.pop(); + } + } + this.value = historyItem.value.split(''); + this.selection = historyItem.selection; + this._lastOp = historyItem.lastOp; + return true; + } + + setSelection(selection: SelectionObject) { + this.selection = { ...selection }; + + if (this.selection.start === this.selection.end) { + if (this.selection.start < this.pattern.firstEditableIndex!) { + this.selection!.start = this.selection!.end = this.pattern.firstEditableIndex as number; + return true; + } + // Set selection to the first editable, non-placeholder character before the selection + // OR to the beginning of the pattern + var index = this.selection.start; + while (index >= this.pattern.firstEditableIndex!) { + if ( + (this.pattern.isEditableIndex(index - 1) && + this.value[index - 1] !== this.placeholderChar) || + index === this.pattern.firstEditableIndex + ) { + this.selection.start = this.selection.end = index; + break; + } + index--; + } + return true; + } + return false; + } +} + +export default InputMask; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8ff760f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,46 @@ +{ + "compilerOptions": { + "jsx": "react", + "target": "es2017", + "outDir": "build/main", + "rootDir": "src", + "moduleResolution": "node", + "module": "commonjs", + "declaration": true, + "inlineSourceMap": true, + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + + // "strict": true /* Enable all strict type-checking options. */, + + /* Strict Type-Checking Options */ + // "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, + // "strictNullChecks": true /* Enable strict null checks. */, + // "strictFunctionTypes": true /* Enable strict checking of function types. */, + // "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, + // "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, + // "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, + + /* Additional Checks */ + "noUnusedLocals": true /* Report errors on unused locals. */, + "noUnusedParameters": true /* Report errors on unused parameters. */, + "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, + "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, + + /* Debugging Options */ + "traceResolution": false /* Report module resolution log messages. */, + "listEmittedFiles": false /* Print names of generated files part of the compilation. */, + "listFiles": false /* Print names of files part of the compilation. */, + "pretty": true /* Stylize errors and messages using color and context. */, + + /* Experimental Options */ + // "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, + // "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, + + "lib": ["es2017", "dom"], + "types": [], + "typeRoots": ["node_modules/@types", "src/types", "node_modules/antd/lib/*"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules/**"], + "compileOnSave": false +} diff --git a/tsconfig.module.json b/tsconfig.module.json new file mode 100644 index 0000000..dfb74fa --- /dev/null +++ b/tsconfig.module.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "target": "esnext", + "outDir": "build/module", + "module": "esnext" + }, + "exclude": [ + "node_modules/**" + ] +}