422 lines
11 KiB
JavaScript
422 lines
11 KiB
JavaScript
import ChangeDetails from '../core/change-details.js';
|
|
import ContinuousTailDetails from '../core/continuous-tail-details.js';
|
|
import { isString, DIRECTION, objectIncludes, forceDirection } from '../core/utils.js';
|
|
import IMask from '../core/holder.js';
|
|
|
|
/** Append flags */
|
|
|
|
/** Extract flags */
|
|
|
|
// see https://github.com/microsoft/TypeScript/issues/6223
|
|
|
|
/** Provides common masking stuff */
|
|
class Masked {
|
|
/** */
|
|
|
|
/** */
|
|
|
|
/** Transforms value before mask processing */
|
|
|
|
/** Transforms each char before mask processing */
|
|
|
|
/** Validates if value is acceptable */
|
|
|
|
/** Does additional processing at the end of editing */
|
|
|
|
/** Format typed value to string */
|
|
|
|
/** Parse string to get typed value */
|
|
|
|
/** Enable characters overwriting */
|
|
|
|
/** */
|
|
|
|
/** */
|
|
|
|
/** */
|
|
|
|
/** */
|
|
|
|
constructor(opts) {
|
|
this._value = '';
|
|
this._update({
|
|
...Masked.DEFAULTS,
|
|
...opts
|
|
});
|
|
this._initialized = true;
|
|
}
|
|
|
|
/** Sets and applies new options */
|
|
updateOptions(opts) {
|
|
if (!this.optionsIsChanged(opts)) return;
|
|
this.withValueRefresh(this._update.bind(this, opts));
|
|
}
|
|
|
|
/** Sets new options */
|
|
_update(opts) {
|
|
Object.assign(this, opts);
|
|
}
|
|
|
|
/** Mask state */
|
|
get state() {
|
|
return {
|
|
_value: this.value,
|
|
_rawInputValue: this.rawInputValue
|
|
};
|
|
}
|
|
set state(state) {
|
|
this._value = state._value;
|
|
}
|
|
|
|
/** Resets value */
|
|
reset() {
|
|
this._value = '';
|
|
}
|
|
get value() {
|
|
return this._value;
|
|
}
|
|
set value(value) {
|
|
this.resolve(value, {
|
|
input: true
|
|
});
|
|
}
|
|
|
|
/** Resolve new value */
|
|
resolve(value, flags) {
|
|
if (flags === void 0) {
|
|
flags = {
|
|
input: true
|
|
};
|
|
}
|
|
this.reset();
|
|
this.append(value, flags, '');
|
|
this.doCommit();
|
|
}
|
|
get unmaskedValue() {
|
|
return this.value;
|
|
}
|
|
set unmaskedValue(value) {
|
|
this.resolve(value, {});
|
|
}
|
|
get typedValue() {
|
|
return this.parse ? this.parse(this.value, this) : this.unmaskedValue;
|
|
}
|
|
set typedValue(value) {
|
|
if (this.format) {
|
|
this.value = this.format(value, this);
|
|
} else {
|
|
this.unmaskedValue = String(value);
|
|
}
|
|
}
|
|
|
|
/** Value that includes raw user input */
|
|
get rawInputValue() {
|
|
return this.extractInput(0, this.displayValue.length, {
|
|
raw: true
|
|
});
|
|
}
|
|
set rawInputValue(value) {
|
|
this.resolve(value, {
|
|
raw: true
|
|
});
|
|
}
|
|
get displayValue() {
|
|
return this.value;
|
|
}
|
|
get isComplete() {
|
|
return true;
|
|
}
|
|
get isFilled() {
|
|
return this.isComplete;
|
|
}
|
|
|
|
/** Finds nearest input position in direction */
|
|
nearestInputPos(cursorPos, direction) {
|
|
return cursorPos;
|
|
}
|
|
totalInputPositions(fromPos, toPos) {
|
|
if (fromPos === void 0) {
|
|
fromPos = 0;
|
|
}
|
|
if (toPos === void 0) {
|
|
toPos = this.displayValue.length;
|
|
}
|
|
return Math.min(this.displayValue.length, toPos - fromPos);
|
|
}
|
|
|
|
/** Extracts value in range considering flags */
|
|
extractInput(fromPos, toPos, flags) {
|
|
if (fromPos === void 0) {
|
|
fromPos = 0;
|
|
}
|
|
if (toPos === void 0) {
|
|
toPos = this.displayValue.length;
|
|
}
|
|
return this.displayValue.slice(fromPos, toPos);
|
|
}
|
|
|
|
/** Extracts tail in range */
|
|
extractTail(fromPos, toPos) {
|
|
if (fromPos === void 0) {
|
|
fromPos = 0;
|
|
}
|
|
if (toPos === void 0) {
|
|
toPos = this.displayValue.length;
|
|
}
|
|
return new ContinuousTailDetails(this.extractInput(fromPos, toPos), fromPos);
|
|
}
|
|
|
|
/** Appends tail */
|
|
appendTail(tail) {
|
|
if (isString(tail)) tail = new ContinuousTailDetails(String(tail));
|
|
return tail.appendTo(this);
|
|
}
|
|
|
|
/** Appends char */
|
|
_appendCharRaw(ch, flags) {
|
|
if (!ch) return new ChangeDetails();
|
|
this._value += ch;
|
|
return new ChangeDetails({
|
|
inserted: ch,
|
|
rawInserted: ch
|
|
});
|
|
}
|
|
|
|
/** Appends char */
|
|
_appendChar(ch, flags, checkTail) {
|
|
if (flags === void 0) {
|
|
flags = {};
|
|
}
|
|
const consistentState = this.state;
|
|
let details;
|
|
[ch, details] = this.doPrepareChar(ch, flags);
|
|
if (ch) {
|
|
details = details.aggregate(this._appendCharRaw(ch, flags));
|
|
|
|
// TODO handle `skip`?
|
|
|
|
// try `autofix` lookahead
|
|
if (!details.rawInserted && this.autofix === 'pad') {
|
|
const noFixState = this.state;
|
|
this.state = consistentState;
|
|
let fixDetails = this.pad(flags);
|
|
const chDetails = this._appendCharRaw(ch, flags);
|
|
fixDetails = fixDetails.aggregate(chDetails);
|
|
|
|
// if fix was applied or
|
|
// if details are equal use skip restoring state optimization
|
|
if (chDetails.rawInserted || fixDetails.equals(details)) {
|
|
details = fixDetails;
|
|
} else {
|
|
this.state = noFixState;
|
|
}
|
|
}
|
|
}
|
|
if (details.inserted) {
|
|
let consistentTail;
|
|
let appended = this.doValidate(flags) !== false;
|
|
if (appended && checkTail != null) {
|
|
// validation ok, check tail
|
|
const beforeTailState = this.state;
|
|
if (this.overwrite === true) {
|
|
consistentTail = checkTail.state;
|
|
for (let i = 0; i < details.rawInserted.length; ++i) {
|
|
checkTail.unshift(this.displayValue.length - details.tailShift);
|
|
}
|
|
}
|
|
let tailDetails = this.appendTail(checkTail);
|
|
appended = tailDetails.rawInserted.length === checkTail.toString().length;
|
|
|
|
// not ok, try shift
|
|
if (!(appended && tailDetails.inserted) && this.overwrite === 'shift') {
|
|
this.state = beforeTailState;
|
|
consistentTail = checkTail.state;
|
|
for (let i = 0; i < details.rawInserted.length; ++i) {
|
|
checkTail.shift();
|
|
}
|
|
tailDetails = this.appendTail(checkTail);
|
|
appended = tailDetails.rawInserted.length === checkTail.toString().length;
|
|
}
|
|
|
|
// if ok, rollback state after tail
|
|
if (appended && tailDetails.inserted) this.state = beforeTailState;
|
|
}
|
|
|
|
// revert all if something went wrong
|
|
if (!appended) {
|
|
details = new ChangeDetails();
|
|
this.state = consistentState;
|
|
if (checkTail && consistentTail) checkTail.state = consistentTail;
|
|
}
|
|
}
|
|
return details;
|
|
}
|
|
|
|
/** Appends optional placeholder at the end */
|
|
_appendPlaceholder() {
|
|
return new ChangeDetails();
|
|
}
|
|
|
|
/** Appends optional eager placeholder at the end */
|
|
_appendEager() {
|
|
return new ChangeDetails();
|
|
}
|
|
|
|
/** Appends symbols considering flags */
|
|
append(str, flags, tail) {
|
|
if (!isString(str)) throw new Error('value should be string');
|
|
const checkTail = isString(tail) ? new ContinuousTailDetails(String(tail)) : tail;
|
|
if (flags != null && flags.tail) flags._beforeTailState = this.state;
|
|
let details;
|
|
[str, details] = this.doPrepare(str, flags);
|
|
for (let ci = 0; ci < str.length; ++ci) {
|
|
const d = this._appendChar(str[ci], flags, checkTail);
|
|
if (!d.rawInserted && !this.doSkipInvalid(str[ci], flags, checkTail)) break;
|
|
details.aggregate(d);
|
|
}
|
|
if ((this.eager === true || this.eager === 'append') && flags != null && flags.input && str) {
|
|
details.aggregate(this._appendEager());
|
|
}
|
|
|
|
// append tail but aggregate only tailShift
|
|
if (checkTail != null) {
|
|
details.tailShift += this.appendTail(checkTail).tailShift;
|
|
// TODO it's a good idea to clear state after appending ends
|
|
// but it causes bugs when one append calls another (when dynamic dispatch set rawInputValue)
|
|
// this._resetBeforeTailState();
|
|
}
|
|
return details;
|
|
}
|
|
remove(fromPos, toPos) {
|
|
if (fromPos === void 0) {
|
|
fromPos = 0;
|
|
}
|
|
if (toPos === void 0) {
|
|
toPos = this.displayValue.length;
|
|
}
|
|
this._value = this.displayValue.slice(0, fromPos) + this.displayValue.slice(toPos);
|
|
return new ChangeDetails();
|
|
}
|
|
|
|
/** Calls function and reapplies current value */
|
|
withValueRefresh(fn) {
|
|
if (this._refreshing || !this._initialized) return fn();
|
|
this._refreshing = true;
|
|
const rawInput = this.rawInputValue;
|
|
const value = this.value;
|
|
const ret = fn();
|
|
this.rawInputValue = rawInput;
|
|
// append lost trailing chars at the end
|
|
if (this.value && this.value !== value && value.indexOf(this.value) === 0) {
|
|
this.append(value.slice(this.displayValue.length), {}, '');
|
|
this.doCommit();
|
|
}
|
|
delete this._refreshing;
|
|
return ret;
|
|
}
|
|
runIsolated(fn) {
|
|
if (this._isolated || !this._initialized) return fn(this);
|
|
this._isolated = true;
|
|
const state = this.state;
|
|
const ret = fn(this);
|
|
this.state = state;
|
|
delete this._isolated;
|
|
return ret;
|
|
}
|
|
doSkipInvalid(ch, flags, checkTail) {
|
|
return Boolean(this.skipInvalid);
|
|
}
|
|
|
|
/** Prepares string before mask processing */
|
|
doPrepare(str, flags) {
|
|
if (flags === void 0) {
|
|
flags = {};
|
|
}
|
|
return ChangeDetails.normalize(this.prepare ? this.prepare(str, this, flags) : str);
|
|
}
|
|
|
|
/** Prepares each char before mask processing */
|
|
doPrepareChar(str, flags) {
|
|
if (flags === void 0) {
|
|
flags = {};
|
|
}
|
|
return ChangeDetails.normalize(this.prepareChar ? this.prepareChar(str, this, flags) : str);
|
|
}
|
|
|
|
/** Validates if value is acceptable */
|
|
doValidate(flags) {
|
|
return (!this.validate || this.validate(this.value, this, flags)) && (!this.parent || this.parent.doValidate(flags));
|
|
}
|
|
|
|
/** Does additional processing at the end of editing */
|
|
doCommit() {
|
|
if (this.commit) this.commit(this.value, this);
|
|
}
|
|
splice(start, deleteCount, inserted, removeDirection, flags) {
|
|
if (inserted === void 0) {
|
|
inserted = '';
|
|
}
|
|
if (removeDirection === void 0) {
|
|
removeDirection = DIRECTION.NONE;
|
|
}
|
|
if (flags === void 0) {
|
|
flags = {
|
|
input: true
|
|
};
|
|
}
|
|
const tailPos = start + deleteCount;
|
|
const tail = this.extractTail(tailPos);
|
|
const eagerRemove = this.eager === true || this.eager === 'remove';
|
|
let oldRawValue;
|
|
if (eagerRemove) {
|
|
removeDirection = forceDirection(removeDirection);
|
|
oldRawValue = this.extractInput(0, tailPos, {
|
|
raw: true
|
|
});
|
|
}
|
|
let startChangePos = start;
|
|
const details = new ChangeDetails();
|
|
|
|
// if it is just deletion without insertion
|
|
if (removeDirection !== DIRECTION.NONE) {
|
|
startChangePos = this.nearestInputPos(start, deleteCount > 1 && start !== 0 && !eagerRemove ? DIRECTION.NONE : removeDirection);
|
|
|
|
// adjust tailShift if start was aligned
|
|
details.tailShift = startChangePos - start;
|
|
}
|
|
details.aggregate(this.remove(startChangePos));
|
|
if (eagerRemove && removeDirection !== DIRECTION.NONE && oldRawValue === this.rawInputValue) {
|
|
if (removeDirection === DIRECTION.FORCE_LEFT) {
|
|
let valLength;
|
|
while (oldRawValue === this.rawInputValue && (valLength = this.displayValue.length)) {
|
|
details.aggregate(new ChangeDetails({
|
|
tailShift: -1
|
|
})).aggregate(this.remove(valLength - 1));
|
|
}
|
|
} else if (removeDirection === DIRECTION.FORCE_RIGHT) {
|
|
tail.unshift();
|
|
}
|
|
}
|
|
return details.aggregate(this.append(inserted, flags, tail));
|
|
}
|
|
maskEquals(mask) {
|
|
return this.mask === mask;
|
|
}
|
|
optionsIsChanged(opts) {
|
|
return !objectIncludes(this, opts);
|
|
}
|
|
typedValueEquals(value) {
|
|
const tval = this.typedValue;
|
|
return value === tval || Masked.EMPTY_VALUES.includes(value) && Masked.EMPTY_VALUES.includes(tval) || (this.format ? this.format(value, this) === this.format(this.typedValue, this) : false);
|
|
}
|
|
pad(flags) {
|
|
return new ChangeDetails();
|
|
}
|
|
}
|
|
Masked.DEFAULTS = {
|
|
skipInvalid: true
|
|
};
|
|
Masked.EMPTY_VALUES = [undefined, null, ''];
|
|
IMask.Masked = Masked;
|
|
|
|
export { Masked as default };
|