diff --git a/packages/imask/example.html b/packages/imask/example.html
index fff5b6e6..b049fbe3 100644
--- a/packages/imask/example.html
+++ b/packages/imask/example.html
@@ -26,7 +26,7 @@
IMask Core Demo
var result = document.getElementById('value');
var unmasked = document.getElementById('unmasked');
var imask = IMask(input, opts).on('accept', () => {
- console.log('accept', imask.value, imask.mask);
+ console.log('accept', imask.value, imask.unmaskedValue, imask.typedValue);
result.innerHTML = imask.value;
unmasked.innerHTML = imask.unmaskedValue;
});
diff --git a/packages/imask/src/controls/html-input-mask-element.ts b/packages/imask/src/controls/html-input-mask-element.ts
index de9a2580..6800eb4f 100644
--- a/packages/imask/src/controls/html-input-mask-element.ts
+++ b/packages/imask/src/controls/html-input-mask-element.ts
@@ -13,7 +13,6 @@ class HTMLInputMaskElement extends HTMLMaskElement {
constructor (input: InputElement) {
super(input);
this.input = input;
- this._handlers = {};
}
/** Returns InputElement selection start */
diff --git a/packages/imask/src/controls/html-mask-element.ts b/packages/imask/src/controls/html-mask-element.ts
index 4396a834..03ff1ed9 100644
--- a/packages/imask/src/controls/html-mask-element.ts
+++ b/packages/imask/src/controls/html-mask-element.ts
@@ -1,13 +1,16 @@
-import MaskElement, { type ElementEvent } from './mask-element';
+import MaskElement, { EventHandlers } from './mask-element';
import IMask from '../core/holder';
+const KEY_Z = 90;
+const KEY_Y = 89;
+
/** Bridge between HTMLElement and {@link Masked} */
export default
abstract class HTMLMaskElement extends MaskElement {
/** HTMLElement to use mask on */
declare input: HTMLElement;
- declare _handlers: {[key: string]: EventListener};
+ declare _handlers: EventHandlers;
abstract value: string;
constructor (input: HTMLElement) {
@@ -15,6 +18,7 @@ abstract class HTMLMaskElement extends MaskElement {
this.input = input;
this._onKeydown = this._onKeydown.bind(this);
this._onInput = this._onInput.bind(this);
+ this._onBeforeinput = this._onBeforeinput.bind(this);
this._onCompositionEnd = this._onCompositionEnd.bind(this);
}
@@ -22,19 +26,16 @@ abstract class HTMLMaskElement extends MaskElement {
return (this.input.getRootNode?.() ?? document) as HTMLDocument;
}
- /**
- Is element in focus
- */
+ /** Is element in focus */
get isActive (): boolean {
return this.input === this.rootElement.activeElement;
}
- /**
- Binds HTMLElement events to mask internal events
- */
- override bindEvents (handlers: {[key in ElementEvent]: EventListener}) {
+ /** Binds HTMLElement events to mask internal events */
+ override bindEvents (handlers: EventHandlers) {
this.input.addEventListener('keydown', this._onKeydown as EventListener);
this.input.addEventListener('input', this._onInput as EventListener);
+ this.input.addEventListener('beforeinput', this._onBeforeinput as EventListener);
this.input.addEventListener('compositionend', this._onCompositionEnd as EventListener);
this.input.addEventListener('drop', handlers.drop);
this.input.addEventListener('click', handlers.click);
@@ -44,9 +45,34 @@ abstract class HTMLMaskElement extends MaskElement {
}
_onKeydown (e: KeyboardEvent) {
+ if (this._handlers.redo && (
+ (e.keyCode === KEY_Z && e.shiftKey && (e.metaKey || e.ctrlKey)) ||
+ (e.keyCode === KEY_Y && e.ctrlKey)
+ )) {
+ e.preventDefault();
+ return this._handlers.redo(e);
+ }
+
+ if (this._handlers.undo && e.keyCode === KEY_Z && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ return this._handlers.undo(e);
+ }
+
if (!e.isComposing) this._handlers.selectionChange(e);
}
+ _onBeforeinput (e: InputEvent) {
+ if (e.inputType === 'historyUndo' && this._handlers.undo) {
+ e.preventDefault();
+ return this._handlers.undo(e);
+ }
+
+ if (e.inputType === 'historyRedo' && this._handlers.redo) {
+ e.preventDefault();
+ return this._handlers.redo(e);
+ }
+ }
+
_onCompositionEnd (e: CompositionEvent) {
this._handlers.input(e);
}
@@ -55,18 +81,17 @@ abstract class HTMLMaskElement extends MaskElement {
if (!e.isComposing) this._handlers.input(e);
}
- /**
- Unbinds HTMLElement events to mask internal events
- */
+ /** Unbinds HTMLElement events to mask internal events */
override unbindEvents () {
this.input.removeEventListener('keydown', this._onKeydown as EventListener);
this.input.removeEventListener('input', this._onInput as EventListener);
+ this.input.removeEventListener('beforeinput', this._onBeforeinput as EventListener);
this.input.removeEventListener('compositionend', this._onCompositionEnd as EventListener);
this.input.removeEventListener('drop', this._handlers.drop);
this.input.removeEventListener('click', this._handlers.click);
this.input.removeEventListener('focus', this._handlers.focus);
this.input.removeEventListener('blur', this._handlers.commit);
- this._handlers = {};
+ this._handlers = {} as EventHandlers;
}
}
diff --git a/packages/imask/src/controls/input-history.ts b/packages/imask/src/controls/input-history.ts
new file mode 100644
index 00000000..85e5402e
--- /dev/null
+++ b/packages/imask/src/controls/input-history.ts
@@ -0,0 +1,50 @@
+import { type Selection } from '../core/utils';
+
+
+export
+type InputHistoryState = {
+ unmaskedValue: string,
+ selection: Selection,
+};
+
+
+export default
+class InputHistory {
+ static MAX_LENGTH = 100;
+ states: InputHistoryState[] = [];
+ currentIndex = 0;
+
+ get currentState (): InputHistoryState | undefined {
+ return this.states[this.currentIndex];
+ }
+
+ get isEmpty (): boolean {
+ return this.states.length === 0;
+ }
+
+ push (state: InputHistoryState) {
+ // if current index points before the last element then remove the future
+ if (this.currentIndex < this.states.length - 1) this.states.length = this.currentIndex + 1;
+ this.states.push(state);
+ if (this.states.length > InputHistory.MAX_LENGTH) this.states.shift();
+ this.currentIndex = this.states.length - 1;
+ }
+
+ go (steps: number): InputHistoryState | undefined {
+ this.currentIndex = Math.min(Math.max(this.currentIndex + steps, 0), this.states.length - 1);
+ return this.currentState;
+ }
+
+ undo () {
+ return this.go(-1);
+ }
+
+ redo () {
+ return this.go(+1);
+ }
+
+ clear () {
+ this.states.length = 0;
+ this.currentIndex = 0;
+ }
+}
diff --git a/packages/imask/src/controls/input.ts b/packages/imask/src/controls/input.ts
index 5fec9dcc..f5c563b2 100644
--- a/packages/imask/src/controls/input.ts
+++ b/packages/imask/src/controls/input.ts
@@ -6,6 +6,7 @@ import MaskElement from './mask-element';
import HTMLInputMaskElement, { type InputElement } from './html-input-mask-element';
import HTMLContenteditableMaskElement from './html-contenteditable-mask-element';
import IMask from '../core/holder';
+import InputHistory, { type InputHistoryState } from './input-history';
export
@@ -32,7 +33,9 @@ class InputMask> {
declare _rawInputValue: string;
declare _selection: Selection;
declare _cursorChanging?: ReturnType;
+ declare _historyChanging?: boolean;
declare _inputEvent?: InputEvent;
+ declare history: InputHistory;
constructor (el: InputMaskElement, opts: Opts) {
this.el =
@@ -46,6 +49,7 @@ class InputMask> {
this._value = '';
this._unmaskedValue = '';
this._rawInputValue = '';
+ this.history = new InputHistory();
this._saveSelection = this._saveSelection.bind(this);
this._onInput = this._onInput.bind(this);
@@ -53,6 +57,8 @@ class InputMask> {
this._onDrop = this._onDrop.bind(this);
this._onFocus = this._onFocus.bind(this);
this._onClick = this._onClick.bind(this);
+ this._onUndo = this._onUndo.bind(this);
+ this._onRedo = this._onRedo.bind(this);
this.alignCursor = this.alignCursor.bind(this);
this.alignCursorFriendly = this.alignCursorFriendly.bind(this);
@@ -94,8 +100,7 @@ class InputMask> {
if (this.value === str) return;
this.masked.value = str;
- this.updateControl();
- this.alignCursor();
+ this.updateControl('auto');
}
/** Unmasked value */
@@ -107,8 +112,7 @@ class InputMask> {
if (this.unmaskedValue === str) return;
this.masked.unmaskedValue = str;
- this.updateControl();
- this.alignCursor();
+ this.updateControl('auto');
}
/** Raw input value */
@@ -133,8 +137,7 @@ class InputMask> {
if (this.masked.typedValueEquals(val)) return;
this.masked.typedValue = val;
- this.updateControl();
- this.alignCursor();
+ this.updateControl('auto');
}
/** Display value */
@@ -151,6 +154,8 @@ class InputMask> {
click: this._onClick,
focus: this._onFocus,
commit: this._onChange,
+ undo: this._onUndo,
+ redo: this._onRedo,
});
}
@@ -207,7 +212,7 @@ class InputMask> {
}
/** Syncronizes view from model value, fires change events */
- updateControl () {
+ updateControl (cursorPos?: number | 'auto') {
const newUnmaskedValue = this.masked.unmaskedValue;
const newValue = this.masked.value;
const newRawInputValue = this.masked.rawInputValue;
@@ -224,7 +229,15 @@ class InputMask> {
this._rawInputValue = newRawInputValue;
if (this.el.value !== newDisplayValue) this.el.value = newDisplayValue;
+
+ if (cursorPos === 'auto') this.alignCursor();
+ else if (cursorPos != null) this.cursorPos = cursorPos;
+
if (isChanged) this._fireChangeEvents();
+ if (!this._historyChanging && (isChanged || this.history.isEmpty)) this.history.push({
+ unmaskedValue: newUnmaskedValue,
+ selection: { start: this.selectionStart, end: this.cursorPos },
+ });
}
/** Updates options with deep equal check, recreates {@link Masked} model if mask type changes */
@@ -341,8 +354,7 @@ class InputMask> {
);
if (removeDirection !== DIRECTION.NONE) cursorPos = this.masked.nearestInputPos(cursorPos, DIRECTION.NONE);
- this.updateControl();
- this.updateCursor(cursorPos);
+ this.updateControl(cursorPos);
delete this._inputEvent;
}
@@ -372,6 +384,24 @@ class InputMask> {
this.alignCursorFriendly();
}
+ _onUndo () {
+ this._applyHistoryState(this.history.undo());
+ }
+
+ _onRedo () {
+ this._applyHistoryState(this.history.redo());
+ }
+
+ _applyHistoryState (state: InputHistoryState | undefined) {
+ if (!state) return;
+
+ this._historyChanging = true;
+ this.unmaskedValue = state.unmaskedValue;
+ this.el.select(state.selection.start, state.selection.end);
+ this._saveSelection();
+ this._historyChanging = false;
+ }
+
/** Unbind view events and removes element reference */
destroy () {
this._unbindEvents();
diff --git a/packages/imask/src/controls/mask-element.ts b/packages/imask/src/controls/mask-element.ts
index 651de629..56913d7b 100644
--- a/packages/imask/src/controls/mask-element.ts
+++ b/packages/imask/src/controls/mask-element.ts
@@ -3,12 +3,19 @@ import IMask from '../core/holder';
export
type ElementEvent =
- 'selectionChange' |
- 'input' |
- 'drop' |
- 'click' |
- 'focus' |
- 'commit';
+ | 'selectionChange'
+ | 'input'
+ | 'drop'
+ | 'click'
+ | 'focus'
+ | 'commit'
+;
+
+export
+type EventHandlers = { [key in ElementEvent]: (...args: any[]) => void } & {
+ undo?: (...args: any[]) => void;
+ redo?: (...args: any[]) => void;
+}
/** Generic element API to use with mask */
export default
@@ -59,7 +66,7 @@ abstract class MaskElement {
/** */
abstract _unsafeSelect (start: number, end: number): void;
/** */
- abstract bindEvents (handlers: {[key in ElementEvent]: Function}): void;
+ abstract bindEvents (handlers: EventHandlers): void;
/** */
abstract unbindEvents (): void
}
diff --git a/packages/imask/test/controls/input-history.ts b/packages/imask/test/controls/input-history.ts
new file mode 100644
index 00000000..10d5d8e4
--- /dev/null
+++ b/packages/imask/test/controls/input-history.ts
@@ -0,0 +1,31 @@
+import assert from 'assert';
+import { describe, it, beforeEach } from 'node:test';
+
+import InputHistory from '../../src/controls/input-history';
+
+
+describe('InputHistory', function () {
+ const history = new InputHistory();
+
+ beforeEach(function () {
+ history.clear();
+ });
+
+ it('should work', function () {
+ const state1 = { unmaskedValue: '1', selection: { start: 0, end: 1 } };
+ const state2 = { unmaskedValue: '2', selection: { start: 1, end: 2 } };
+
+ history.push(state1);
+ history.push(state2);
+ assert.equal(history.currentIndex, 1);
+
+ assert.equal(history.undo(), state1);
+ assert.equal(history.currentIndex, 0);
+
+ assert.equal(history.redo(), state2);
+ assert.equal(history.currentIndex, 1);
+
+ history.clear();
+ assert.equal(history.states.length, 0);
+ });
+});