Skip to content

Commit

Permalink
fix #951
Browse files Browse the repository at this point in the history
  • Loading branch information
uNmAnNeR committed Feb 26, 2024
1 parent 035456f commit 58f01fa
Show file tree
Hide file tree
Showing 10 changed files with 231 additions and 68 deletions.
10 changes: 6 additions & 4 deletions packages/imask/example.html
Expand Up @@ -12,10 +12,12 @@ <h1>IMask Core Demo</h1>
<!-- <script src="https://unpkg.com/imask"></script> -->
<script type="text/javascript">
const opts = {
mask: Number,
min: -10,
max: 2000,
autofix: true,
mask: IMask.MaskedEnum,
enum: Array.from({ length: 12 }, (_, i) =>
new Date(0, i).toLocaleString(window.navigator.language, { month: 'long' })
),
lazy: false,
matchValue: (estr, istr, matchFrom) => IMask.MaskedEnum.DEFAULTS.matchValue(estr.toLowerCase(), istr.toLowerCase(), matchFrom),
};

const input = document.getElementById('input');
Expand Down
10 changes: 6 additions & 4 deletions packages/imask/src/core/action-details.ts
Expand Up @@ -29,10 +29,12 @@ class ActionDetails {
--this.oldSelection.start;
}

// double check right part
while (this.value.slice(this.cursorPos) !== this.oldValue.slice(this.oldSelection.end)) {
if (this.value.length - this.cursorPos < this.oldValue.length - this.oldSelection.end) ++this.oldSelection.end;
else ++this.cursorPos;
if (this.insertedCount) {
// double check right part
while (this.value.slice(this.cursorPos) !== this.oldValue.slice(this.oldSelection.end)) {
if (this.value.length - this.cursorPos < this.oldValue.length - this.oldSelection.end) ++this.oldSelection.end;
else ++this.cursorPos;
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/imask/src/masked/base.ts
Expand Up @@ -48,9 +48,9 @@ type MaskedOptions<M extends Masked=Masked, Props extends keyof M=never> = Parti
/** Provides common masking stuff */
export default
abstract class Masked<Value=any> {
static DEFAULTS: Record<string, any> = {
static DEFAULTS: Pick<MaskedOptions, 'skipInvalid'> = {
skipInvalid: true,
} satisfies Partial<MaskedOptions>;
};
static EMPTY_VALUES: Array<any> = [undefined, null, ''];

/** */
Expand Down
3 changes: 2 additions & 1 deletion packages/imask/src/masked/date.ts
Expand Up @@ -49,7 +49,8 @@ class MaskedDate extends MaskedPattern<DateValue> {
to: 9999,
}
});
static DEFAULTS: Record<string, any> = {
static DEFAULTS = {
...MaskedPattern.DEFAULTS,
mask: Date,
pattern: 'd{.}`m{.}`Y',
format: (date: DateValue, masked: Masked): string => {
Expand Down
77 changes: 38 additions & 39 deletions packages/imask/src/masked/dynamic.ts
Expand Up @@ -30,8 +30,6 @@ type HandleState = MaskedDynamicState | MaskedState;
/** Dynamic mask for choosing appropriate mask in run-time */
export default
class MaskedDynamic<Value=any> extends Masked<Value> {
static DEFAULTS: Partial<MaskedDynamicOptions>;

declare mask: DynamicMaskType;
/** Currently chosen mask */
declare currentMask?: Masked;
Expand All @@ -46,6 +44,44 @@ class MaskedDynamic<Value=any> extends Masked<Value> {
declare _eager?: this['eager'];
declare _skipInvalid?: this['skipInvalid'];

static DEFAULTS: typeof Masked.DEFAULTS & Pick<MaskedDynamic, 'dispatch'> = {
...Masked.DEFAULTS,
dispatch: (appended, masked, flags, tail) => {
if (!masked.compiledMasks.length) return;

const inputValue = masked.rawInputValue;

// simulate input
const inputs = masked.compiledMasks.map((m, index) => {
const isCurrent = masked.currentMask === m;
const startInputPos = isCurrent ? m.displayValue.length : m.nearestInputPos(m.displayValue.length, DIRECTION.FORCE_LEFT);

if (m.rawInputValue !== inputValue) {
m.reset();
m.append(inputValue, { raw: true });
} else if (!isCurrent) {
m.remove(startInputPos);
}
m.append(appended, masked.currentMaskFlags(flags));
m.appendTail(tail);

return {
index,
weight: m.rawInputValue.length,
totalInputPositions: m.totalInputPositions(
0,
Math.max(startInputPos, m.nearestInputPos(m.displayValue.length, DIRECTION.FORCE_LEFT)),
),
};
});

// pop masks with longer values first
inputs.sort((i1, i2) => i2.weight - i1.weight || i2.totalInputPositions - i1.totalInputPositions);

return masked.compiledMasks[inputs[0].index];
}
};

constructor (opts?: MaskedDynamicOptions) {
super({
...MaskedDynamic.DEFAULTS,
Expand Down Expand Up @@ -381,42 +417,5 @@ class MaskedDynamic<Value=any> extends Masked<Value> {
}
}

MaskedDynamic.DEFAULTS = {
dispatch: (appended, masked, flags, tail) => {
if (!masked.compiledMasks.length) return;

const inputValue = masked.rawInputValue;

// simulate input
const inputs = masked.compiledMasks.map((m, index) => {
const isCurrent = masked.currentMask === m;
const startInputPos = isCurrent ? m.displayValue.length : m.nearestInputPos(m.displayValue.length, DIRECTION.FORCE_LEFT);

if (m.rawInputValue !== inputValue) {
m.reset();
m.append(inputValue, { raw: true });
} else if (!isCurrent) {
m.remove(startInputPos);
}
m.append(appended, masked.currentMaskFlags(flags));
m.appendTail(tail);

return {
index,
weight: m.rawInputValue.length,
totalInputPositions: m.totalInputPositions(
0,
Math.max(startInputPos, m.nearestInputPos(m.displayValue.length, DIRECTION.FORCE_LEFT)),
),
};
});

// pop masks with longer values first
inputs.sort((i1, i2) => i2.weight - i1.weight || i2.totalInputPositions - i1.totalInputPositions);

return masked.compiledMasks[inputs[0].index];
}
};


IMask.MaskedDynamic = MaskedDynamic;
81 changes: 70 additions & 11 deletions packages/imask/src/masked/enum.ts
@@ -1,48 +1,107 @@
import MaskedPattern, { type MaskedPatternOptions } from './pattern';
import MaskedPattern, { MaskedPatternState, type MaskedPatternOptions } from './pattern';
import { AppendFlags } from './base';
import IMask from '../core/holder';
import ChangeDetails from '../core/change-details';
import { DIRECTION } from '../core/utils';
import { TailDetails } from '../core/tail-details';
import ContinuousTailDetails from '../core/continuous-tail-details';


export
type MaskedEnumOptions = Omit<MaskedPatternOptions, 'mask'> & Pick<MaskedEnum, 'enum'>;
type MaskedEnumOptions = Omit<MaskedPatternOptions, 'mask'> & Pick<MaskedEnum, 'enum'> & Partial<Pick<MaskedEnum, 'matchValue'>>;

export
type MaskedEnumPatternOptions = MaskedPatternOptions & Partial<Pick<MaskedEnum, 'enum'>>;
type MaskedEnumPatternOptions = MaskedPatternOptions & Partial<Pick<MaskedEnum, 'enum' | 'matchValue'>>;


/** Pattern which validates enum values */
export default
class MaskedEnum extends MaskedPattern {
declare enum: Array<string>;
/** Match enum value */
declare matchValue: (enumStr: string, inputStr: string, matchFrom: number) => boolean;

static DEFAULTS: typeof MaskedPattern.DEFAULTS & Pick<MaskedEnum, 'matchValue'> = {
...MaskedPattern.DEFAULTS,
matchValue: (estr, istr, matchFrom) => estr.indexOf(istr, matchFrom) === matchFrom,
};

constructor (opts?: MaskedEnumOptions) {
super(opts as MaskedPatternOptions); // mask will be created in _update
super({
...MaskedEnum.DEFAULTS,
...opts,
} as MaskedPatternOptions); // mask will be created in _update
}

override updateOptions (opts: Partial<MaskedEnumOptions>) {
super.updateOptions(opts);
}

override _update (opts: Partial<MaskedEnumOptions>) {
const { enum: _enum, ...eopts }: MaskedEnumPatternOptions = opts;
const { enum: enum_, ...eopts }: MaskedEnumPatternOptions = opts;

if (_enum) {
const lengths = _enum.map(e => e.length);
if (enum_) {
const lengths = enum_.map(e => e.length);
const requiredLength = Math.min(...lengths);
const optionalLength = Math.max(...lengths) - requiredLength;

eopts.mask = '*'.repeat(requiredLength);
if (optionalLength) eopts.mask += '[' + '*'.repeat(optionalLength) + ']';

this.enum = _enum;
this.enum = enum_;
}

super._update(eopts);
}

override doValidate (flags: AppendFlags): boolean {
return this.enum.some(e => e.indexOf(this.unmaskedValue) === 0) &&
super.doValidate(flags);
override _appendCharRaw (ch: string, flags: AppendFlags<MaskedPatternState>={}): ChangeDetails {
const matchFrom = Math.min(this.nearestInputPos(0, DIRECTION.FORCE_RIGHT), this.value.length);

const matches = this.enum.filter(e => this.matchValue(e, this.unmaskedValue + ch, matchFrom));

if (matches.length) {
if (matches.length === 1) {
this._forEachBlocksInRange(0, this.value.length, (b, bi) => {
const mch = matches[0][bi];
if (bi >= this.value.length || mch === b.value) return;

b.reset();
b._appendChar(mch, flags);
});
}

const d = super._appendCharRaw(matches[0][this.value.length], flags);

if (matches.length === 1) {
matches[0].slice(this.unmaskedValue.length).split('').forEach(mch => d.aggregate(super._appendCharRaw(mch)));
}

return d;
}

return new ChangeDetails();
}

override extractTail (fromPos: number=0, toPos: number=this.displayValue.length): TailDetails {
// just drop tail
return new ContinuousTailDetails('', fromPos);
}

override remove (fromPos: number=0, toPos: number=this.displayValue.length): ChangeDetails {
if (fromPos === toPos) return new ChangeDetails();

const matchFrom = Math.min(super.nearestInputPos(0, DIRECTION.FORCE_RIGHT), this.value.length);

let pos: number;
for (pos = fromPos; pos >= 0; --pos) {
const matches = this.enum.filter(e => this.matchValue(e, this.value.slice(matchFrom, pos), matchFrom));
if (matches.length > 1) break;
}

const details = super.remove(pos, toPos);
details.tailShift += pos - fromPos;

return details;
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/imask/src/masked/number.ts
Expand Up @@ -24,7 +24,8 @@ export default
class MaskedNumber extends Masked<number> {
static UNMASKED_RADIX = '.';
static EMPTY_VALUES: Array<null | undefined | string | number> = [...Masked.EMPTY_VALUES, 0];
static DEFAULTS: Partial<MaskedNumberOptions> = {
static DEFAULTS = {
...Masked.DEFAULTS,
mask: Number,
radix: ',',
thousandsSeparator: '',
Expand Down
7 changes: 4 additions & 3 deletions packages/imask/src/masked/pattern.ts
Expand Up @@ -52,10 +52,11 @@ type BlockExtraOptions = {
/** Pattern mask */
export default
class MaskedPattern<Value=string> extends Masked<Value> {
static DEFAULTS: Record<string, any> = {
static DEFAULTS = {
...Masked.DEFAULTS,
lazy: true,
placeholderChar: '_'
} satisfies Partial<MaskedPattern>;
};
static STOP_CHAR = '`';
static ESCAPE_CHAR = '\\';
static InputDefinition = PatternInputDefinition;
Expand Down Expand Up @@ -326,7 +327,7 @@ class MaskedPattern<Value=string> extends Masked<Value> {
return details;
}

override extractTail (fromPos: number=0, toPos: number=this.displayValue.length): ChunksTailDetails {
override extractTail (fromPos: number=0, toPos: number=this.displayValue.length): TailDetails {
const chunkTail = new ChunksTailDetails();
if (fromPos === toPos) return chunkTail;

Expand Down
62 changes: 62 additions & 0 deletions packages/imask/test/core/action-details.ts
@@ -0,0 +1,62 @@
import assert from 'assert';
import { describe, it } from 'node:test';

import ActionDetails from '../../src/core/action-details';
import { DIRECTION } from '../../src/core/utils';


describe('ActionDetails', function () {
it('should handle insert', function () {
const ad = new ActionDetails({
value: '1234',
cursorPos: 3,
oldValue: '124',
oldSelection: { start: 2, end: 2 },
});

assert.equal(ad.removedCount, 0);
assert.equal(ad.insertedCount, 1);
assert.equal(ad.removeDirection, DIRECTION.NONE);
});

it('should handle backspace', function () {
const ad = new ActionDetails({
value: '124',
cursorPos: 2,
oldValue: '1234',
oldSelection: { start: 3, end: 3 },
});

assert.equal(ad.removedCount, 1);
assert.equal(ad.insertedCount, 0);
assert.equal(ad.removeDirection, DIRECTION.LEFT);
});

it('should handle delete', function () {
const ad = new ActionDetails({
value: '124',
cursorPos: 2,
oldValue: '1234',
oldSelection: { start: 2, end: 2 },
});

assert.equal(ad.removedCount, 1);
assert.equal(ad.insertedCount, 0);
assert.equal(ad.removeDirection, DIRECTION.RIGHT);
});

it('should fix old selection end', function () {
const ad = new ActionDetails({
value: '1111',
cursorPos: 4,
oldValue: '0000',
// this is not common for input text
// but sometimes happens because of HMR/autocomplete
oldSelection: { start: 0, end: 0 },
});

assert.equal(ad.removedCount, 4, 'invalid removedCount');
assert.equal(ad.insertedCount, 4, 'invalid insertedCount');
assert.equal(ad.removeDirection, DIRECTION.NONE);
});
});

0 comments on commit 58f01fa

Please sign in to comment.