@maskito/core
Version:
The main zero-dependency and framework-agnostic Maskito's package to create an input mask
914 lines (882 loc) • 37.3 kB
JavaScript
function getContentEditableSelection(element) {
const { anchorOffset = 0, focusOffset = 0 } = element.ownerDocument.getSelection() || {};
const from = Math.min(anchorOffset, focusOffset);
const to = Math.max(anchorOffset, focusOffset);
return [from, to];
}
function setContentEditableSelection(element, [from, to]) {
var _a, _b, _c, _d;
const document = element.ownerDocument;
const range = document.createRange();
range.setStart(element.firstChild || element, Math.min(from, (_b = (_a = element.textContent) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0));
range.setEnd(element.lastChild || element, Math.min(to, (_d = (_c = element.textContent) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0));
const selection = document.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
class ContentEditableAdapter {
constructor(element) {
this.element = element;
this.maxLength = Infinity;
}
get value() {
return this.element.innerText.replace(/\n\n$/, '\n');
}
set value(value) {
// Setting into innerHTML of element with `white-space: pre;` style
this.element.innerHTML = value.replace(/\n$/, '\n\n');
}
get selectionStart() {
return getContentEditableSelection(this.element)[0];
}
get selectionEnd() {
return getContentEditableSelection(this.element)[1];
}
setSelectionRange(from, to) {
setContentEditableSelection(this.element, [from !== null && from !== void 0 ? from : 0, to !== null && to !== void 0 ? to : 0]);
}
select() {
this.setSelectionRange(0, this.value.length);
}
}
function maskitoAdaptContentEditable(element) {
const adapter = new ContentEditableAdapter(element);
return new Proxy(element, {
get(target, prop) {
if (prop in adapter) {
return adapter[prop];
}
const nativeProperty = target[prop];
return typeof nativeProperty === 'function'
? nativeProperty.bind(target)
: nativeProperty;
},
// eslint-disable-next-line @typescript-eslint/max-params
set(target, prop, val, receiver) {
return Reflect.set(prop in adapter ? adapter : target, prop, val, receiver);
},
});
}
const MASKITO_DEFAULT_ELEMENT_PREDICATE = (e) => e.isContentEditable
? maskitoAdaptContentEditable(e)
: e.querySelector('input,textarea') ||
e;
const MASKITO_DEFAULT_OPTIONS = {
mask: /^.*$/,
preprocessors: [],
postprocessors: [],
plugins: [],
overwriteMode: 'shift',
};
class MaskHistory {
constructor() {
this.now = null;
this.past = [];
this.future = [];
}
undo() {
const state = this.past.pop();
if (state && this.now) {
this.future.push(this.now);
this.updateElement(state, 'historyUndo');
}
}
redo() {
const state = this.future.pop();
if (state && this.now) {
this.past.push(this.now);
this.updateElement(state, 'historyRedo');
}
}
updateHistory(state) {
if (!this.now) {
this.now = state;
return;
}
const isValueChanged = this.now.value !== state.value;
const isSelectionChanged = this.now.selection.some((item, index) => item !== state.selection[index]);
if (!isValueChanged && !isSelectionChanged) {
return;
}
if (isValueChanged) {
this.past.push(this.now);
this.future = [];
}
this.now = state;
}
updateElement(state, inputType) {
this.now = state;
this.updateElementState(state, { inputType, data: null });
}
}
function areElementValuesEqual(sampleState, ...states) {
return states.every(({ value }) => value === sampleState.value);
}
function areElementStatesEqual(sampleState, ...states) {
return states.every(({ value, selection }) => value === sampleState.value &&
selection[0] === sampleState.selection[0] &&
selection[1] === sampleState.selection[1]);
}
function applyOverwriteMode({ value, selection }, newCharacters, mode) {
const [from, to] = selection;
const computedMode = typeof mode === 'function' ? mode({ value, selection }) : mode;
return {
value,
selection: computedMode === 'replace'
? [from, Math.max(from + newCharacters.length, to)]
: [from, to],
};
}
function isFixedCharacter(char) {
return typeof char === 'string';
}
// eslint-disable-next-line @typescript-eslint/max-params
function getLeadingFixedCharacters(mask, validatedValuePart, newCharacter, initialElementState) {
let leadingFixedCharacters = '';
for (let i = validatedValuePart.length; i < mask.length; i++) {
const charConstraint = mask[i] || '';
const isInitiallyExisted = (initialElementState === null || initialElementState === void 0 ? void 0 : initialElementState.value[i]) === charConstraint;
if (!isFixedCharacter(charConstraint) ||
(charConstraint === newCharacter && !isInitiallyExisted)) {
return leadingFixedCharacters;
}
leadingFixedCharacters += charConstraint;
}
return leadingFixedCharacters;
}
function validateValueWithMask(value, maskExpression) {
if (Array.isArray(maskExpression)) {
return (value.length === maskExpression.length &&
Array.from(value).every((char, i) => {
const charConstraint = maskExpression[i] || '';
return isFixedCharacter(charConstraint)
? char === charConstraint
: char.match(charConstraint);
}));
}
return maskExpression.test(value);
}
function guessValidValueByPattern(elementState, mask, initialElementState) {
let maskedFrom = null;
let maskedTo = null;
const maskedValue = Array.from(elementState.value).reduce((validatedCharacters, char, charIndex) => {
const leadingCharacters = getLeadingFixedCharacters(mask, validatedCharacters, char, initialElementState);
const newValidatedChars = validatedCharacters + leadingCharacters;
const charConstraint = mask[newValidatedChars.length] || '';
if (maskedFrom === null && charIndex >= elementState.selection[0]) {
maskedFrom = newValidatedChars.length;
}
if (maskedTo === null && charIndex >= elementState.selection[1]) {
maskedTo = newValidatedChars.length;
}
if (isFixedCharacter(charConstraint)) {
return newValidatedChars + charConstraint;
}
if (char.match(charConstraint)) {
return newValidatedChars + char;
}
return leadingCharacters.startsWith(char)
? newValidatedChars
: validatedCharacters;
}, '');
const trailingFixedCharacters = getLeadingFixedCharacters(mask, maskedValue, '', initialElementState);
return {
value: validateValueWithMask(maskedValue + trailingFixedCharacters, mask)
? maskedValue + trailingFixedCharacters
: maskedValue,
selection: [maskedFrom !== null && maskedFrom !== void 0 ? maskedFrom : maskedValue.length, maskedTo !== null && maskedTo !== void 0 ? maskedTo : maskedValue.length],
};
}
function guessValidValueByRegExp({ value, selection }, maskRegExp) {
const [from, to] = selection;
let newFrom = from;
let newTo = to;
const validatedValue = Array.from(value).reduce((validatedValuePart, char, i) => {
const newPossibleValue = validatedValuePart + char;
if (from === i) {
newFrom = validatedValuePart.length;
}
if (to === i) {
newTo = validatedValuePart.length;
}
return newPossibleValue.match(maskRegExp) ? newPossibleValue : validatedValuePart;
}, '');
return {
value: validatedValue,
selection: [
Math.min(newFrom, validatedValue.length),
Math.min(newTo, validatedValue.length),
],
};
}
function calibrateValueByMask(elementState, mask, initialElementState = null) {
if (validateValueWithMask(elementState.value, mask)) {
return elementState;
}
const { value, selection } = Array.isArray(mask)
? guessValidValueByPattern(elementState, mask, initialElementState)
: guessValidValueByRegExp(elementState, mask);
return {
selection,
value: Array.isArray(mask) ? value.slice(0, mask.length) : value,
};
}
function removeFixedMaskCharacters(initialElementState, mask) {
if (!Array.isArray(mask)) {
return initialElementState;
}
const [from, to] = initialElementState.selection;
const selection = [];
const unmaskedValue = Array.from(initialElementState.value).reduce((rawValue, char, i) => {
const charConstraint = mask[i] || '';
if (i === from) {
selection.push(rawValue.length);
}
if (i === to) {
selection.push(rawValue.length);
}
return isFixedCharacter(charConstraint) && charConstraint === char
? rawValue
: rawValue + char;
}, '');
if (selection.length < 2) {
selection.push(...new Array(2 - selection.length).fill(unmaskedValue.length));
}
return {
value: unmaskedValue,
selection: [selection[0], selection[1]],
};
}
class MaskModel {
constructor(initialElementState, maskOptions) {
this.maskOptions = maskOptions;
this.unmaskInitialState = { value: '', selection: [0, 0] };
this.value = '';
this.selection = [0, 0];
const expression = this.getMaskExpression(initialElementState);
const { value, selection } = calibrateValueByMask(initialElementState, expression);
this.unmaskInitialState = removeFixedMaskCharacters({ value, selection }, expression);
this.value = value;
this.selection = selection;
}
addCharacters(newCharacters) {
const { value, selection, maskOptions } = this;
const initialElementState = { value, selection };
const { selection: [from, to], } = applyOverwriteMode(initialElementState, newCharacters, maskOptions.overwriteMode);
const maskExpression = this.getMaskExpression({
value: value.slice(0, from) + newCharacters + value.slice(to),
selection: [from + newCharacters.length, from + newCharacters.length],
});
const [unmaskedFrom, unmaskedTo] = applyOverwriteMode(this.unmaskInitialState, newCharacters, maskOptions.overwriteMode).selection;
const newUnmaskedLeadingValuePart = this.unmaskInitialState.value.slice(0, unmaskedFrom) + newCharacters;
const newCaretIndex = newUnmaskedLeadingValuePart.length;
const maskedElementState = calibrateValueByMask({
value: newUnmaskedLeadingValuePart +
this.unmaskInitialState.value.slice(unmaskedTo),
selection: [newCaretIndex, newCaretIndex],
}, maskExpression, initialElementState);
const prevLeadingPart = value.slice(0, from);
const newLeadingPartState = calibrateValueByMask({
value: newUnmaskedLeadingValuePart,
selection: [newCaretIndex, newCaretIndex],
}, maskExpression, initialElementState);
const isInvalidCharsInsertion = newLeadingPartState.value === prevLeadingPart ||
(newLeadingPartState.value.length < prevLeadingPart.length &&
removeFixedMaskCharacters(newLeadingPartState, maskExpression).value ===
this.unmaskInitialState.value.slice(0, unmaskedFrom));
if (isInvalidCharsInsertion ||
areElementStatesEqual(this, maskedElementState) // If typing new characters does not change value
) {
throw new Error('Invalid mask value');
}
this.value = maskedElementState.value;
this.selection = maskedElementState.selection;
}
deleteCharacters() {
const [from, to] = this.selection;
if (from === to || !to) {
return;
}
const { value } = this;
const maskExpression = this.getMaskExpression({
value: value.slice(0, from) + value.slice(to),
selection: [from, from],
});
const initialElementState = { value};
const [unmaskedFrom, unmaskedTo] = this.unmaskInitialState.selection;
const newUnmaskedValue = this.unmaskInitialState.value.slice(0, unmaskedFrom) +
this.unmaskInitialState.value.slice(unmaskedTo);
const maskedElementState = calibrateValueByMask({ value: newUnmaskedValue, selection: [unmaskedFrom, unmaskedFrom] }, maskExpression, initialElementState);
this.value = maskedElementState.value;
this.selection = maskedElementState.selection;
}
getMaskExpression(elementState) {
const { mask } = this.maskOptions;
return typeof mask === 'function' ? mask(elementState) : mask;
}
}
class EventListener {
constructor(element) {
this.element = element;
this.listeners = [];
}
listen(eventType, fn, options) {
const untypedFn = fn;
this.element.addEventListener(eventType, untypedFn, options);
this.listeners.push(() => this.element.removeEventListener(eventType, untypedFn, options));
}
destroy() {
this.listeners.forEach((stopListen) => stopListen());
}
}
const HotkeyModifier = {
CTRL: 1 << 0,
ALT: 1 << 1,
SHIFT: 1 << 2,
META: 1 << 3,
};
// TODO add variants that can be processed correctly
const HotkeyCode = {
Y: 89,
Z: 90,
};
/**
* Checks if the passed keyboard event match the required hotkey.
*
* @example
* input.addEventListener('keydown', (event) => {
* if (isHotkey(event, HotkeyModifier.CTRL | HotkeyModifier.SHIFT, HotkeyCode.Z)) {
* // redo hotkey pressed
* }
* })
*
* @return will return `true` only if the {@link HotkeyCode} matches and only the necessary
* {@link HotkeyModifier modifiers} have been pressed
*/
function isHotkey(event, modifiers, hotkeyCode) {
return (event.ctrlKey === !!(modifiers & HotkeyModifier.CTRL) &&
event.altKey === !!(modifiers & HotkeyModifier.ALT) &&
event.shiftKey === !!(modifiers & HotkeyModifier.SHIFT) &&
event.metaKey === !!(modifiers & HotkeyModifier.META) &&
/**
* We intentionally use legacy {@link KeyboardEvent#keyCode `keyCode`} property. It is more
* "keyboard-layout"-independent than {@link KeyboardEvent#key `key`} or {@link KeyboardEvent#code `code`} properties.
* @see {@link https://github.com/taiga-family/maskito/issues/315 `KeyboardEvent#code` issue}
*/
event.keyCode === hotkeyCode);
}
function isRedo(event) {
return (isHotkey(event, HotkeyModifier.CTRL, HotkeyCode.Y) || // Windows
isHotkey(event, HotkeyModifier.CTRL | HotkeyModifier.SHIFT, HotkeyCode.Z) || // Windows & Android
isHotkey(event, HotkeyModifier.META | HotkeyModifier.SHIFT, HotkeyCode.Z) // macOS & iOS
);
}
function isUndo(event) {
return (isHotkey(event, HotkeyModifier.CTRL, HotkeyCode.Z) || // Windows & Android
isHotkey(event, HotkeyModifier.META, HotkeyCode.Z) // macOS & iOS
);
}
/**
* Sets value to element, and dispatches input event
* if you passed ELementState, it also sets selection range
*
* @example
* maskitoUpdateElement(input, newValue);
* maskitoUpdateElement(input, elementState);
*
* @see {@link https://github.com/taiga-family/maskito/issues/804 issue}
*
* @return void
*/
function maskitoUpdateElement(element, valueOrElementState) {
var _a;
const initialValue = element.value;
if (typeof valueOrElementState === 'string') {
element.value = valueOrElementState;
}
else {
const [from, to] = valueOrElementState.selection;
element.value = valueOrElementState.value;
if (element.matches(':focus')) {
(_a = element.setSelectionRange) === null || _a === void 0 ? void 0 : _a.call(element, from, to);
}
}
if (element.value !== initialValue) {
element.dispatchEvent(new Event('input',
/**
* React handles this event only on bubbling phase
*
* here is the list of events that are processed in the capture stage, others are processed in the bubbling stage
* https://github.com/facebook/react/blob/cb2439624f43c510007f65aea5c50a8bb97917e4/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L222
*/
{ bubbles: true }));
}
}
function getLineSelection({ value, selection }, isForward) {
const [from, to] = selection;
if (from !== to) {
return [from, to];
}
const nearestBreak = isForward
? value.slice(from).indexOf('\n') + 1 || value.length
: value.slice(0, to).lastIndexOf('\n') + 1;
const selectFrom = isForward ? from : nearestBreak;
const selectTo = isForward ? nearestBreak : to;
return [selectFrom, selectTo];
}
function getNotEmptySelection({ value, selection }, isForward) {
const [from, to] = selection;
if (from !== to) {
return [from, to];
}
const notEmptySelection = isForward ? [from, to + 1] : [from - 1, to];
return notEmptySelection.map((x) => Math.min(Math.max(x, 0), value.length));
}
const TRAILING_SPACES_REG = /\s+$/g;
const LEADING_SPACES_REG = /^\s+/g;
const SPACE_REG = /\s/;
function getWordSelection({ value, selection }, isForward) {
const [from, to] = selection;
if (from !== to) {
return [from, to];
}
if (isForward) {
const valueAfterSelectionStart = value.slice(from);
const [leadingSpaces] = valueAfterSelectionStart.match(LEADING_SPACES_REG) || [
'',
];
const nearestWordEndIndex = valueAfterSelectionStart
.trimStart()
.search(SPACE_REG);
return [
from,
nearestWordEndIndex !== -1
? from + leadingSpaces.length + nearestWordEndIndex
: value.length,
];
}
const valueBeforeSelectionEnd = value.slice(0, to);
const [trailingSpaces] = valueBeforeSelectionEnd.match(TRAILING_SPACES_REG) || [''];
const selectedWordLength = valueBeforeSelectionEnd
.trimEnd()
.split('')
.reverse()
.findIndex((char) => SPACE_REG.exec(char));
return [
selectedWordLength !== -1 ? to - trailingSpaces.length - selectedWordLength : 0,
to,
];
}
/* eslint-disable @typescript-eslint/no-restricted-types */
/**
* @internal
*/
function maskitoPipe(processors = []) {
return (initialData, ...readonlyArgs) => processors.reduce((data, fn) => (Object.assign(Object.assign({}, data), fn(data, ...readonlyArgs))), initialData);
}
function maskitoTransform(valueOrState, maskitoOptions) {
const options = Object.assign(Object.assign({}, MASKITO_DEFAULT_OPTIONS), maskitoOptions);
const preprocessor = maskitoPipe(options.preprocessors);
const postprocessor = maskitoPipe(options.postprocessors);
const initialElementState = typeof valueOrState === 'string'
? { value: valueOrState, selection: [0, 0] }
: valueOrState;
const { elementState } = preprocessor({ elementState: initialElementState, data: '' }, 'validation');
const maskModel = new MaskModel(elementState, options);
const { value, selection } = postprocessor(maskModel, initialElementState);
return typeof valueOrState === 'string' ? value : { value, selection };
}
/**
* All `input` events with `inputType=deleteContentBackward` always follows `beforeinput` event with the same `inputType`.
* If `beforeinput[inputType=deleteContentBackward]` is prevented, subsequent `input[inputType=deleteContentBackward]` is prevented too.
* There is an exception – Android devices with Microsoft SwiftKey Keyboard in Mobile Chrome.
* These devices ignores `preventDefault` for `beforeinput` event if Backspace is pressed.
* @see https://github.com/taiga-family/maskito/issues/2135#issuecomment-2980729647
* ___
* TODO: track Chromium bug report and delete this plugin after bug fix
* https://issues.chromium.org/issues/40885402
*/
function createBrokenDefaultPlugin() {
return (element) => {
const eventListener = new EventListener(element);
let isVirtualAndroidKeyboard = false;
let beforeinputEvent;
let value = element.value;
eventListener.listen('keydown', ({ key }) => {
isVirtualAndroidKeyboard = key === 'Unidentified';
});
eventListener.listen('beforeinput', (event) => {
beforeinputEvent = event;
value = element.value;
});
eventListener.listen('input', (event) => {
if (isVirtualAndroidKeyboard &&
beforeinputEvent.defaultPrevented &&
beforeinputEvent.inputType === 'deleteContentBackward' &&
event.inputType === 'deleteContentBackward') {
element.value = value;
}
}, { capture: true });
return () => eventListener.destroy();
};
}
function maskitoChangeEventPlugin() {
return (element) => {
if (element.isContentEditable) {
return;
}
let value = element.value;
const valueListener = () => {
value = element.value;
};
const blurListener = () => {
if (element.value !== value) {
element.dispatchEvent(new Event('change', { bubbles: true }));
}
};
element.addEventListener('focus', valueListener);
element.addEventListener('change', valueListener);
element.addEventListener('blur', blurListener);
return () => {
element.removeEventListener('focus', valueListener);
element.removeEventListener('change', valueListener);
element.removeEventListener('blur', blurListener);
};
};
}
const SPACE = ' ';
/**
* 1. Android user (with G-board keyboard or similar) presses 1st space
* ```
* {type: "beforeinput", data: " ", inputType: "insertText"}
* ```
* 2. User presses 2nd space
* ```
* // Android tries to delete previously inserted space
* {type: "beforeinput", inputType: "deleteContentBackward"}
* {type: "beforeinput", data: ". ", inputType: "insertText"}
* ```
* ---------
* 1. MacOS user presses 1st space
* ```
* {type: "beforeinput", data: " ", inputType: "insertText"}
* ```
* 2. User presses 2nd space
* ```
* // MacOS automatically run `element.setSelectionRange(indexBeforeSpace, indexAfterSpace)` and then
* {type: "beforeinput", data: ". ", inputType: "insertText"}
* ```
* ---------
* @see https://github.com/taiga-family/maskito/issues/2023
*/
function createDoubleSpacePlugin() {
let prevValue = '';
let prevCaretIndex = 0;
let prevEvent = null;
let prevRejectedSpace = false;
return (element) => {
const eventListener = new EventListener(element);
eventListener.listen('beforeinput', (event) => {
var _a, _b;
const { value, selectionStart, selectionEnd } = element;
const rejectedSpace = (prevEvent === null || prevEvent === void 0 ? void 0 : prevEvent.inputType) === 'insertText' &&
(prevEvent === null || prevEvent === void 0 ? void 0 : prevEvent.data) === SPACE &&
!value.slice(0, Number(selectionEnd)).endsWith(SPACE);
if (event.inputType === 'insertText' && event.data === `.${SPACE}`) {
if ((prevEvent === null || prevEvent === void 0 ? void 0 : prevEvent.inputType) === 'deleteContentBackward' &&
prevRejectedSpace) {
// Android
element.value = prevValue;
(_a = element.setSelectionRange) === null || _a === void 0 ? void 0 : _a.call(element, prevCaretIndex, prevCaretIndex);
}
else if (rejectedSpace) {
// Mac OS
(_b = element.setSelectionRange) === null || _b === void 0 ? void 0 : _b.call(element, selectionStart, selectionStart);
}
}
prevRejectedSpace = rejectedSpace;
prevEvent = event;
prevValue = value;
prevCaretIndex = Number((rejectedSpace ? prevCaretIndex : selectionEnd) === value.length
? selectionEnd
: selectionStart);
});
return () => eventListener.destroy();
};
}
function maskitoInitialCalibrationPlugin(customOptions) {
return (element, options) => {
var _a, _b;
const from = (_a = element.selectionStart) !== null && _a !== void 0 ? _a : 0;
const to = (_b = element.selectionEnd) !== null && _b !== void 0 ? _b : 0;
maskitoUpdateElement(element, {
value: maskitoTransform(element.value, customOptions || options),
selection: [from, to],
});
};
}
function maskitoStrictCompositionPlugin() {
return (element, maskitoOptions) => {
const listener = (event) => {
var _a, _b;
if (event.inputType !== 'insertCompositionText') {
return;
}
const selection = [
(_a = element.selectionStart) !== null && _a !== void 0 ? _a : 0,
(_b = element.selectionEnd) !== null && _b !== void 0 ? _b : 0,
];
const elementState = {
selection,
value: element.value,
};
const validatedState = maskitoTransform(elementState, maskitoOptions);
if (!areElementStatesEqual(elementState, validatedState)) {
event.preventDefault();
maskitoUpdateElement(element, validatedState);
}
};
element.addEventListener('input', listener);
return () => element.removeEventListener('input', listener);
};
}
const BUILT_IN_PLUGINS = [createDoubleSpacePlugin(), createBrokenDefaultPlugin()];
class Maskito extends MaskHistory {
constructor(element, maskitoOptions) {
super();
this.element = element;
this.maskitoOptions = maskitoOptions;
this.isTextArea = this.element.nodeName === 'TEXTAREA';
this.eventListener = new EventListener(this.element);
this.options = Object.assign(Object.assign({}, MASKITO_DEFAULT_OPTIONS), this.maskitoOptions);
this.upcomingElementState = null;
this.preprocessor = maskitoPipe(this.options.preprocessors);
this.postprocessor = maskitoPipe(this.options.postprocessors);
this.teardowns = this.options.plugins
.concat(BUILT_IN_PLUGINS)
.map((plugin) => plugin(this.element, this.options));
this.updateHistory(this.elementState);
this.eventListener.listen('keydown', (event) => {
if (isRedo(event)) {
event.preventDefault();
return this.redo();
}
if (isUndo(event)) {
event.preventDefault();
return this.undo();
}
});
this.eventListener.listen('beforeinput', (event) => {
var _a, _b, _c;
const isForward = event.inputType.includes('Forward');
this.updateHistory(this.elementState);
switch (event.inputType) {
case 'deleteByCut':
case 'deleteContentBackward':
case 'deleteContentForward':
return this.handleDelete({
event,
isForward,
selection: getNotEmptySelection(this.elementState, isForward),
});
case 'deleteHardLineBackward':
case 'deleteHardLineForward':
case 'deleteSoftLineBackward':
case 'deleteSoftLineForward':
return this.handleDelete({
event,
isForward,
selection: getLineSelection(this.elementState, isForward),
force: true,
});
case 'deleteWordBackward':
case 'deleteWordForward':
return this.handleDelete({
event,
isForward,
selection: getWordSelection(this.elementState, isForward),
});
case 'historyRedo':
event.preventDefault();
return this.redo();
// historyUndo/historyRedo will not be triggered if value was modified programmatically
case 'historyUndo':
event.preventDefault();
return this.undo();
case 'insertCompositionText':
return; // will be handled inside `compositionend` event
case 'insertLineBreak':
case 'insertParagraph':
return this.handleEnter(event);
case 'insertReplacementText':
/**
* According {@link https://www.w3.org/TR/input-events-2 W3C specification}:
* > `insertReplacementText` – insert or replace existing text by means of a spell checker,
* > auto-correct, writing suggestions or similar.
* ___
* Firefox emits `insertReplacementText` event for its suggestion/autofill and for spell checker.
* However, it is impossible to detect which part of the textfield value is going to be replaced
* (`selectionStart` and `selectionEnd` just equal to the last caret position).
* ___
* Chrome does not fire `beforeinput` event for its suggestion/autofill.
* It emits only `input` event with `inputType` and `data` set to `undefined`.
* ___
* All these browser limitations make us to validate the result value later in `input` event.
*/
return;
case 'insertFromDrop':
case 'insertFromPaste':
case 'insertText':
default:
return this.handleInsert(event, (_c = (_a = event.data) !== null && _a !== void 0 ? _a :
// `event.data` for `contentEditable` is always `null` for paste/drop events
(_b = event.dataTransfer) === null || _b === void 0 ? void 0 : _b.getData('text/plain')) !== null && _c !== void 0 ? _c : '');
}
});
this.eventListener.listen('input', () => {
if (this.upcomingElementState &&
!areElementStatesEqual(this.upcomingElementState, this.elementState)) {
this.updateElementState(this.upcomingElementState);
}
this.upcomingElementState = null;
}, { capture: true });
this.eventListener.listen('input', ({ inputType }) => {
if (inputType === 'insertCompositionText') {
return; // will be handled inside `compositionend` event
}
this.ensureValueFitsMask();
this.updateHistory(this.elementState);
});
this.eventListener.listen('compositionend', () => {
this.ensureValueFitsMask();
this.updateHistory(this.elementState);
});
}
destroy() {
this.eventListener.destroy();
this.teardowns.forEach((teardown) => teardown === null || teardown === void 0 ? void 0 : teardown());
}
updateElementState({ value, selection }, eventInit) {
const initialValue = this.elementState.value;
this.updateValue(value);
this.updateSelectionRange(selection);
if (eventInit && initialValue !== value) {
this.dispatchInputEvent(eventInit);
}
}
get elementState() {
const { value, selectionStart, selectionEnd } = this.element;
return {
value,
selection: [selectionStart !== null && selectionStart !== void 0 ? selectionStart : 0, selectionEnd !== null && selectionEnd !== void 0 ? selectionEnd : 0],
};
}
get maxLength() {
const { maxLength } = this.element;
return maxLength === -1 ? Infinity : maxLength;
}
updateSelectionRange([from, to]) {
var _a;
const { element } = this;
if (element.matches(':focus') &&
(element.selectionStart !== from || element.selectionEnd !== to)) {
(_a = element.setSelectionRange) === null || _a === void 0 ? void 0 : _a.call(element, from, to);
}
}
updateValue(value) {
/**
* Don't "disturb" unnecessarily `value`-setter
* (i.e. it breaks React controlled input behavior)
*/
if (this.element.value !== value || this.element.isContentEditable) {
this.element.value = value;
}
}
ensureValueFitsMask() {
this.updateElementState(maskitoTransform(this.elementState, this.options), {
inputType: 'insertText',
data: null,
});
}
dispatchInputEvent(eventInit = {
inputType: 'insertText',
data: null,
}) {
if (globalThis.InputEvent) {
this.element.dispatchEvent(new InputEvent('input', Object.assign(Object.assign({}, eventInit), { bubbles: true, cancelable: false })));
}
}
handleDelete({ event, selection, isForward, }) {
const initialState = {
value: this.elementState.value,
selection,
};
const { elementState } = this.preprocessor({
elementState: initialState,
data: '',
}, isForward ? 'deleteForward' : 'deleteBackward');
const maskModel = new MaskModel(elementState, this.options);
maskModel.deleteCharacters();
const newElementState = this.postprocessor(maskModel, initialState);
if (areElementValuesEqual(initialState, elementState, maskModel, newElementState)) {
const [from, to] = elementState.selection;
event.preventDefault();
// User presses Backspace/Delete for the fixed value
return this.updateSelectionRange(isForward ? [to, to] : [from, from]);
}
this.upcomingElementState = newElementState;
}
handleInsert(event, data) {
const { options, maxLength, elementState: initialElementState } = this;
const [from, to] = initialElementState.selection;
const { elementState, data: insertedText = data } = this.preprocessor({
data,
elementState: initialElementState,
}, 'insert');
const maskModel = new MaskModel(elementState, options);
try {
maskModel.addCharacters(insertedText);
}
catch (_a) {
return event.preventDefault();
}
this.upcomingElementState = this.clampState(this.postprocessor(maskModel, initialElementState));
/**
* When textfield value length is already equal to attribute `maxlength`,
* pressing any key (even with valid value) does not emit `input` event
* (except to the case when user replaces some characters by selection).
*/
const noInputEventDispatch = initialElementState.value.length >= maxLength && from === to;
if (noInputEventDispatch) {
if (options.overwriteMode === 'replace' &&
!areElementStatesEqual(this.upcomingElementState, initialElementState)) {
this.dispatchInputEvent({ inputType: 'insertText', data });
}
else {
/**
* This `beforeinput` event will not be followed by `input` event –
* clear computed state to avoid any possible side effect
* for new possible `input` event without preceding `beforeinput` event
* (e.g. browser autofill, `document.execCommand('delete')` etc.)
*/
this.upcomingElementState = null;
}
}
}
handleEnter(event) {
if (this.isTextArea || this.element.isContentEditable) {
this.handleInsert(event, '\n');
}
}
clampState({ value, selection }) {
const [from, to] = selection;
const max = this.maxLength;
return {
value: value.slice(0, max),
selection: [Math.min(from, max), Math.min(to, max)],
};
}
}
export { MASKITO_DEFAULT_ELEMENT_PREDICATE, MASKITO_DEFAULT_OPTIONS, Maskito, maskitoAdaptContentEditable, maskitoChangeEventPlugin, maskitoInitialCalibrationPlugin, maskitoPipe, maskitoStrictCompositionPlugin, maskitoTransform, maskitoUpdateElement };