Skip to content

Commit

Permalink
undo / redo (#981)
Browse files Browse the repository at this point in the history
add undo / redo
  • Loading branch information
uNmAnNeR committed Dec 25, 2023
1 parent 5a9e8f7 commit 3d769a2
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 31 deletions.
2 changes: 1 addition & 1 deletion packages/imask/example.html
Expand Up @@ -26,7 +26,7 @@ <h1>IMask Core Demo</h1>
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;
});
Expand Down
1 change: 0 additions & 1 deletion packages/imask/src/controls/html-input-mask-element.ts
Expand Up @@ -13,7 +13,6 @@ class HTMLInputMaskElement extends HTMLMaskElement {
constructor (input: InputElement) {
super(input);
this.input = input;
this._handlers = {};
}

/** Returns InputElement selection start */
Expand Down
51 changes: 38 additions & 13 deletions packages/imask/src/controls/html-mask-element.ts
@@ -1,40 +1,41 @@
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) {
super();
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);
}

get rootElement (): HTMLDocument {
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);
Expand All @@ -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);
}
Expand All @@ -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;
}
}

Expand Down
50 changes: 50 additions & 0 deletions 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;
}
}
48 changes: 39 additions & 9 deletions packages/imask/src/controls/input.ts
Expand Up @@ -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
Expand All @@ -32,7 +33,9 @@ class InputMask<Opts extends FactoryArg=Record<string, unknown>> {
declare _rawInputValue: string;
declare _selection: Selection;
declare _cursorChanging?: ReturnType<typeof setTimeout>;
declare _historyChanging?: boolean;
declare _inputEvent?: InputEvent;
declare history: InputHistory;

constructor (el: InputMaskElement, opts: Opts) {
this.el =
Expand All @@ -46,13 +49,16 @@ class InputMask<Opts extends FactoryArg=Record<string, unknown>> {
this._value = '';
this._unmaskedValue = '';
this._rawInputValue = '';
this.history = new InputHistory();

this._saveSelection = this._saveSelection.bind(this);
this._onInput = this._onInput.bind(this);
this._onChange = this._onChange.bind(this);
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);

Expand Down Expand Up @@ -94,8 +100,7 @@ class InputMask<Opts extends FactoryArg=Record<string, unknown>> {
if (this.value === str) return;

this.masked.value = str;
this.updateControl();
this.alignCursor();
this.updateControl('auto');
}

/** Unmasked value */
Expand All @@ -107,8 +112,7 @@ class InputMask<Opts extends FactoryArg=Record<string, unknown>> {
if (this.unmaskedValue === str) return;

this.masked.unmaskedValue = str;
this.updateControl();
this.alignCursor();
this.updateControl('auto');
}

/** Raw input value */
Expand All @@ -133,8 +137,7 @@ class InputMask<Opts extends FactoryArg=Record<string, unknown>> {
if (this.masked.typedValueEquals(val)) return;

this.masked.typedValue = val;
this.updateControl();
this.alignCursor();
this.updateControl('auto');
}

/** Display value */
Expand All @@ -151,6 +154,8 @@ class InputMask<Opts extends FactoryArg=Record<string, unknown>> {
click: this._onClick,
focus: this._onFocus,
commit: this._onChange,
undo: this._onUndo,
redo: this._onRedo,
});
}

Expand Down Expand Up @@ -207,7 +212,7 @@ class InputMask<Opts extends FactoryArg=Record<string, unknown>> {
}

/** 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;
Expand All @@ -224,7 +229,15 @@ class InputMask<Opts extends FactoryArg=Record<string, unknown>> {
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 */
Expand Down Expand Up @@ -341,8 +354,7 @@ class InputMask<Opts extends FactoryArg=Record<string, unknown>> {
);
if (removeDirection !== DIRECTION.NONE) cursorPos = this.masked.nearestInputPos(cursorPos, DIRECTION.NONE);

this.updateControl();
this.updateCursor(cursorPos);
this.updateControl(cursorPos);
delete this._inputEvent;
}

Expand Down Expand Up @@ -372,6 +384,24 @@ class InputMask<Opts extends FactoryArg=Record<string, unknown>> {
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();
Expand Down
21 changes: 14 additions & 7 deletions packages/imask/src/controls/mask-element.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
31 changes: 31 additions & 0 deletions 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);
});
});

0 comments on commit 3d769a2

Please sign in to comment.