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); + }); +});