Библиотека ExtJS/Sencha / [Из песочницы] Фильтрация вводимых символов в Ext.form.field.Number

в 16:40, , рубрики: Новости

Думаю, каждый, кто писал WEB-приложение с активным использованием JavaScript на стороне клиента, сталкивался с проблемой десятичного разделителя. И решение этой проблемы отнюдь нетривиально, как может показаться на первый взгляд. В ExtJS использован простой в реализации и управлении подход: числовому полю указывается символ, который считается разделителем, и ввод других символов, исключая цифры и "-", запрещен. Однако этот подход, как мне кажется, имеет один существенный недостаток: когда используется несколько раскладок десятичный разделитель на цифровой клавиатуре соответствует разным символам. Как это исправить описано ниже.
Начнем сначала, или как устроена фильтрация в стандартном Ext.form.field.Number

Согласно документации, у компонента есть свойство disableKeyFilter, которое отвечает за фильтрацию вводимых символов и наследуется от текстового поля Ext.form.field.Text. Поищем упомянутое свойство в исходнике текстового поля. Единственное его упоминание в коде находится в методе initEvents, где на событие keypress навешивается обработчик filterKeys. Теперь «прогуляемся» по иерархии классов от текстового до числового поля и поищем переопределённый метод filterKeys, а не найдя ничего, будем ковырять найденный. Внутри метода, в принципе, ничего особенного нет: фильтрация заключается в проверке вводимого символа на соответствие регулярному выражению, которое может быть задано при конфигурировании компонента. Теперь посмотрим в документацию числового поля и увидим, что параметр maskRe при конфигурировании задать нельзя, т.е. задать-то можно вот только как он обработается непонятно. Лезем в исходники числового поля и в методе initComponent() видим:
if (me.disableKeyFilter !== true) {
allowed = me.baseChars + '';
if (me.allowDecimals) {
allowed += me.decimalSeparator;
}
if (me.minValue < 0) {
allowed += '-';
}
allowed = Ext.String.escapeRegex(allowed);
me.maskRe = new RegExp('[' + allowed + ']');
if (me.autoStripChars) {
me.stripCharsRe = new RegExp('[^' + allowed + ']', 'gi');
}
}

Другими словами, если фильтрация умышленно не выключена, то компонент создает maskRe самостоятельно на основе заданных настроек. В стандартном варианте вводимый символ должен быть одним из следующих '.-0123456789'. Вот, собственно, и весь фильтр.
Заготовка плагина

Кто не знает как пишутся плагины, идет читать документацию. Для остальных ничего сложного в приведенном ниже коде нет.
Ext.define('Ext.plugin.form.field.NumberInputFilter', {
alias : 'plugin.numberinputfilter',
extend : 'Ext.AbstractPlugin',
init : function(field) {
// ничего не делать, если плагин применяется не к числовому полю
if (!(field && field.isXType('numberfield'))) {
return;
}
Ext.apply(field, {
// переопределяем стандартный метод класса,
// пока он один в один повторяет метод из Ext.form.field.Text
filterKeys : function(e){
if (e.ctrlKey && !e.altKey) {
return;
}
var key = e.getKey(),
charCode = String.fromCharCode(e.getCharCode());
if(Ext.isGecko && (e.isNavKeyPress() || key === e.BACKSPACE || (key === e.DELETE && e.button === -1))){
return;
}
if(!Ext.isGecko && e.isSpecialKey() && !charCode){
return;
}
if(!this.maskRe.test(charCode)){
e.stopEvent();
}
}
});
}
});

Подмена десятичного разделителя

Ввиду того, что самыми распространенными разделителями являются точка "." и запятая ",", поэтому это будут разделители по умолчанию. Добавим плагину конфигурационное свойство allowedDecimalSeparators, конструктор, устанавливающий при необходимости значение этого свойства по умолчанию, а также саму обработку в методе filterKeys().
Ext.define('Ext.plugin.form.field.NumberInputFilter', {
alias : 'plugin.numberinputfilter',
extend : 'Ext.AbstractPlugin',
constructor : function(cfg) {
cfg = cfg || {};
// формирование настроек по умолчанию
Ext.applyIf(cfg, {
allowedDecimalSeparators : ',.'
});
Ext.apply(this, cfg);
},
init : function(field) {
// ничего не делать, если плагин применяется не к числовому полю
if (!(field && field.isXType('numberfield'))) {
return;
}
Ext.apply(field, {
// переопределяем стандартный метод класса
filterKeys : function(e){
if (e.ctrlKey && !e.altKey) {
return;
}
var key = e.getKey(),
charCode = String.fromCharCode(e.getCharCode());
if(Ext.isGecko && (e.isNavKeyPress() || key === e.BACKSPACE || (key === e.DELETE && e.button === -1))){
return;
}
if(!Ext.isGecko && e.isSpecialKey() && !charCode){
return;
}
// begin hack
if (charCode != this.decimalSeparator && this.allowedDecimalSeparators.indexOf(charCode) != -1) {
// если вводимый символ не десятичный разделитель,
// но является одним из альтернативных,
// заменяем его на десятичный разделитель
charCode = this.decimalSeparator;
if (Ext.isIE) {
// в IE код нажатой клавиши можно подменить напрямую
e.browserEvent.keyCode = charCode.charCodeAt(0);
} else if (Ext.isGecko) {
// для gecko-движка тормозим событие
e.stopEvent();
// создаем новое событие с измененным кодом нажатой клавиши
var newEvent = document.createEvent('KeyEvents');
// обязательно событие должно быть отменяемым,
// т.к. оно может быть отменено, если десятичный
// разделитель уже введен в поле
newEvent.initKeyEvent(
e.browserEvent.type,
e.browserEvent.bubbles,
true, //cancellable
e.browserEvent.view,
e.browserEvent.ctrlKey,
e.browserEvent.altKey,
e.browserEvent.shiftKey,
e.browserEvent.metaKey,
0, // keyCode
charCode.charCodeAt(0) // charCode
);
e.getTarget().dispatchEvent(newEvent);
// событие сгенерировано, дальше делать ничего не нужно.
return;
} else if (Ext.isWebKit) {
// тормозим событие
e.stopEvent();
// в webkit initKeyboardEvent не работает, делаем через TextEvent
if (this.maskRe.test(charCode)) {
var newEvent = document.createEvent('TextEvent');
newEvent.initTextEvent(
'textInput',
e.browserEvent.bubbles,
true,
e.browserEvent.view,
charCode
);
e.getTarget().dispatchEvent(newEvent);
}
return;
}
}
// end hack
if(!this.maskRe.test(charCode)){
e.stopEvent();
}
}
});
}
});

Теперь любой из поддерживаемых разделителей будет заменяться на правильный. Однако, разделителей можно ввести куда и сколько угодно. Исправим сие недоразумение.
Поддержка псевдомаски для вводимого значиения

Под поддержкой псевдомаски будем понимать невозможность введения недопустимого символа, а также допустимого символа в недопустимое место. Например, знак минус "-" не в начале числа, или несколько десятичных разделителей в строке. Как было написано выше, фильтрация символов осуществляется через регулярное выражение. Однако для поддержки маски ввода нужно нечто большее, поэтому добавим полю ещё один метод checkValue(), который будет принимать в качестве аргумента новый введенный символ и проверять получаемое значение на соответствие псевдомаске.
Условно строковое представление числового значения можно разбить на подстроки «до» и «после» десятичного разделителя. Так и будем проверять:
checkValue : function(newChar) {
// берем введенное в input значение
var raw = this.getRawValue();
if (Ext.isEmpty(raw)) {
// если оно пустое, то верным символом будет:
// - десятичный разделитель
// - знак минус "-", если отрицательные числа поддерживаются
// - любая цифра
return (newChar == this.decimalSeparator || (this.minValue < 0) && newChar == '-') || /^d$/.test(newChar);
}
// в проверке нет смысла,...
if (raw.length == this.maxLength) {
// ...если длина введенной строки достигла максимального значения
return false;
}
if (newChar == this.decimalSeparator && (!this.allowDecimals || raw.indexOf(this.decimalSeparator) != -1)) {
// ...если введен десятичный разделитель, и дробные числа запрещены,
// либо десятичный разделитель не первый в строке
return false;
}
// формируем предполагаемое значение
raw += newChar;
raw = raw.split(new RegExp(Ext.String.escapeRegex(this.decimalSeparator)));
return (!raw[0] || this.intRe.test(raw[0])) && (!raw[1] || this.decRe.test(raw[1]));
}

Приведенный код достаточно подробно прокомментирован, неясным остаются intRe и decRe. Это — регулярные выражения для проверки целой и дробной части числа соответственно, которые будут формироваться при подключении плагина в ещё одном добавленном полю методе updateDecimalPrecision().
// метод обновляет значение свойства decimalPrecision числового поля
// и обновляет регулярные выражения для псевдомаски
updateDecimalPrecision : function(prec, force) {
if (prec == this.decimalPrecision && force !== true) {
return;
}
if (!Ext.isNumber(prec) || prec < 1) {
// выключаем дробные значения, если задана некорректная точность
this.allowDecimals = false;
} else {
this.decimalPrecision = prec;
}
// формируем регулярку для целой части
var intRe = '^';
if (this.minValue < 0) {
intRe += '-?';
}
// integerPrecision - аналог decimalPrecision для целой части,
// свойство задается при конфигурировании числового поля
intRe += '\d' + (Ext.isNumber(this.integerPrecision) ? '{1,' + this.integerPrecision + '}' : '+') + '$';
this.intRe = new RegExp(intRe);
if (this.allowDecimals) {
// формируем регулярку для дробной части
this.decRe = new RegExp('^\d{1,' + this.decimalPrecision + '}$');
} else {
delete this.decRe;
}
}

Описанный метод надо вызвать у поля в конце метода init() создаваемого плагина.
Полученный плагин вполне работоспособен, однако, не лишен некоторых неприятных особенностей: например, введенное значение, десятичная часть которого полностью занимает отведенные ей разряды, нельзя удалить, выделив его. Для борьбы с таким положением дел немного усовершенствуем код checkValue(), добавив обработку выделенной части текста в поле. В результате код будет выглядеть вот так
checkValue : function(newChar) {
// берем введенное в input значение
var raw = this.getRawValue();
// получаем dom-элемент
var el = this.inputEl.dom;
// находим индекс начала и конца выделения
var start = getSelectionStart(el);
var end = getSelectionEnd(el);
if (start != end) {
// удаляем выделенный текст из предполагаемого значения
raw = raw.substring(0, start) + raw.substring(end);
}
if (Ext.isEmpty(raw)) {
// если оно пустое, то верным символом будет:
// - десятичный разделитель
// - знак минус "-", если отрицательные числа поддерживаются
// - любая цифра
return (newChar == this.decimalSeparator || (this.minValue < 0) && newChar == '-') || /^d$/.test(newChar);
}
// в проверке нет смысла,...
if (raw.length == this.maxLength) {
// ...если длина введенной строки достигла максимального значения
return false;
}
if (newChar == this.decimalSeparator && (!this.allowDecimals || raw.indexOf(this.decimalSeparator) != -1)) {
// ...если введен десятичный разделитель, и дробные числа запрещены,
// либо десятичный разделитель не первый в строке
return false;
}
// формируем предполагаемое значение
raw = raw.substring(0, start) + newChar + raw.substring(start);
raw = raw.split(new RegExp(Ext.String.escapeRegex(this.decimalSeparator)));
return (!raw[0] || this.intRe.test(raw[0])) && (!raw[1] || this.decRe.test(raw[1]));
}

На этом все. На всякий случай привожу полный код плагина и демонстрационного примера.
Ext.define('Ext.plugin.form.field.NumberInputFilter', {
alias: 'plugin.numberinputfilter',
extend: 'Ext.AbstractPlugin',
constructor : function(cfg) {
cfg = cfg || {};
Ext.applyIf(cfg, {
allowedDecimalSeparators : ',.'
});
Ext.apply(this, cfg);
},
init : function(field) {
if (!(field && field.isXType('numberfield'))) {
return;
}
Ext.apply(field, {
allowedDecimalSeparators : this.allowedDecimalSeparators,
checkValue : function(newChar) {
var raw = this.getRawValue();
var el = this.inputEl.dom;
// функции взяты отсюда http://javascript.nwbox.com/cursor_position/
// и подключены отдельным файлом cursor.js
var start = getSelectionStart(el);
var end = getSelectionEnd(el);
if (start != end) {
// удаляем выделенный текст из предполагаемого значения
raw = raw.substring(0, start) + raw.substring(end);
}
if (Ext.isEmpty(raw)) {
return (newChar == this.decimalSeparator || (this.minValue < 0) && newChar == '-') || /^d$/.test(newChar);
}
if (raw.length == this.maxLength) {
return false;
}
if (newChar == this.decimalSeparator && (!this.allowDecimals || raw.indexOf(this.decimalSeparator) != -1)) {
return false;
}
// формируем предполагаемое значение
raw = raw.substring(0, start) + newChar + raw.substring(start);
raw = raw.split(new RegExp(Ext.String.escapeRegex(this.decimalSeparator)));
return (!raw[0] || this.intRe.test(raw[0])) && (!raw[1] || this.decRe.test(raw[1]));
},
filterKeys : function(e){
if (e.ctrlKey && !e.altKey) {
return;
}
var key = e.getKey(),
charCode = String.fromCharCode(e.getCharCode());

if(Ext.isGecko && (e.isNavKeyPress() || key === e.BACKSPACE || (key === e.DELETE && e.button === -1))){
return;
}

if(!Ext.isGecko && e.isSpecialKey() && !charCode){
return;
}
// begin hack
if (charCode != this.decimalSeparator && this.allowedDecimalSeparators.indexOf(charCode) != -1) {
// если вводимый символ не десятичный разделитель,
// но является одним из альтернативных,
// заменяем его на десятичный разделитель
charCode = this.decimalSeparator;
if (Ext.isIE) {
// в IE код нажатой клавиши можно подменить напрямую
e.browserEvent.keyCode = charCode.charCodeAt(0);
} else if (Ext.isGecko) {
// для gecko-движка тормозим событие
e.stopEvent();
// создаем новое событие с измененным кодом нажатой клавиши
var newEvent = document.createEvent('KeyEvents');
// обязательно событие должно быть отменяемым,
// т.к. оно может быть отменено, если десятичный
// разделитель уже введен в поле
newEvent.initKeyEvent(
e.browserEvent.type,
e.browserEvent.bubbles,
true, //cancellable
e.browserEvent.view,
e.browserEvent.ctrlKey,
e.browserEvent.altKey,
e.browserEvent.shiftKey,
e.browserEvent.metaKey,
0, // keyCode
charCode.charCodeAt(0) // charCode
);
e.getTarget().dispatchEvent(newEvent);
// событие сгенерировано, дальше делать ничего не нужно.
return;
} else if (Ext.isWebKit) {
// тормозим событие
e.stopEvent();
// в webkit initKeyboardEvent не работает, делаем через TextEvent
if (this.checkValue(charCode)) {
var newEvent = document.createEvent('TextEvent');
newEvent.initTextEvent(
'textInput',
e.browserEvent.bubbles,
true,
e.browserEvent.view,
charCode
);
e.getTarget().dispatchEvent(newEvent);
}
return;
}
}
if (!this.checkValue(charCode)) {
e.stopEvent();
}
// end hack
},
updateDecimalPrecision : function(prec, force) {
if (prec == this.decimalPrecision && force !== true) {
return;
}
if (!Ext.isNumber(prec) || prec < 1) {
this.allowDecimals = false;
} else {
this.decimalPrecision = prec;
}
var intRe = '^';
if (this.minValue < 0) {
intRe += '-?';
}
intRe += '\d' + (Ext.isNumber(this.integerPrecision) ? '{1,' + this.integerPrecision + '}' : '+') + '$';
this.intRe = new RegExp(intRe);
if (this.allowDecimals) {
this.decRe = new RegExp('^\d{1,' + this.decimalPrecision + '}$');
} else {
delete this.decRe;
}
},

fixPrecision : function(value) {
// support decimalSeparators
if (Ext.isString(value)) {
value = value.replace(new RegExp('[' + Ext.String.escapeRegex(this.allowedDecimalSeparators + this.decimalSeparator) + ']'), '.');
}
// end hack
var me = this,
nan = isNaN(value),
precision = me.decimalPrecision;
if (nan || !value) {
return nan ? '' : value;
} else if (!me.allowDecimals || precision <= 0) {
precision = 0;
}
return parseFloat(Ext.Number.toFixed(parseFloat(value), precision));
}
});
field.updateDecimalPrecision(field.decimalPrecision, true);
}
});

Ext.onReady(function() {
Ext.create('Ext.window.Window', {
renderTo : Ext.getBody(),
width : 300,
height : 230,
minWidth : 300,
minHeight : 230,
closable : false,
bodyStyle : 'padding:5px',
layout : 'border',
title : 'NumberInputFilterPlugin - Demo',
items : [{
region : 'north',
xtype : 'fieldset',
defaults : {
xtype : 'numberfield',
hideTrigger : true,
msgTarget : 'side',
autoFitErrors : true
},
title : 'without plugin',
items : [{
fieldLabel : 'simple'
},{
fieldLabel : 'autoStripChars',
autoStripChars : true
}]
},{
region : 'center',
xtype : 'fieldset',
title : 'with plugin',
defaults : {
xtype : 'numberfield',
hideTrigger : true,
msgTarget : 'side',
autoFitErrors : true
},
layout : 'anchor',
items : [{
fieldLabel : 'non negative',
minValue : 0,
plugins : Ext.create('plugin.numberinputfilter')
},{
fieldLabel : '"@,./#" as decimal separators',
plugins : Ext.create('plugin.numberinputfilter', {
allowedDecimalSeparators : '@,./#'
})
}]
}]
}).show();
Ext.tip.QuickTipManager.init();
});


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js