Песочница →
Фильтрация вводимых символов в Ext.form.field.Number
Думаю, каждый, кто писал WEB-приложение с активным использованием JavaScript на стороне клиента, сталкивался с проблемой десятичного разделителя. И решение этой проблемы отнюдь нетривиально, как может показаться на первый взгляд. В ExtJS использован простой в реализации и управлении подход: числовому полю указывается символ, который считается разделителем, и ввод других символов, исключая цифры и "-", запрещен. Однако этот подход, как мне кажется, имеет один существенный недостаток: когда используется несколько раскладок десятичный разделитель на цифровой клавиатуре соответствует разным символам. Как это исправить описано ниже.
Согласно документации, у компонента есть свойство disableKeyFilter, которое отвечает за фильтрацию вводимых символов и наследуется от текстового поля Ext.form.field.Text. Поищем упомянутое свойство в исходнике текстового поля. Единственное его упоминание в коде находится в методе initEvents, где на событие keypress навешивается обработчик filterKeys. Теперь «прогуляемся» по иерархии классов от текстового до числового поля и поищем переопределённый метод filterKeys, а не найдя ничего, будем ковырять найденный. Внутри метода, в принципе, ничего особенного нет: фильтрация заключается в проверке вводимого символа на соответствие регулярному выражению, которое может быть задано при конфигурировании компонента. Теперь посмотрим в документацию числового поля и увидим, что параметр maskRe при конфигурировании задать нельзя, т.е. задать-то можно вот только как он обработается непонятно. Лезем в исходники числового поля и в методе initComponent() видим:
Другими словами, если фильтрация умышленно не выключена, то компонент создает maskRe самостоятельно на основе заданных настроек. В стандартном варианте вводимый символ должен быть одним из следующих '.-0123456789'. Вот, собственно, и весь фильтр.
Кто не знает как пишутся плагины, идет читать документацию. Для остальных ничего сложного в приведенном ниже коде нет.
Ввиду того, что самыми распространенными разделителями являются точка "." и запятая ",", поэтому это будут разделители по умолчанию. Добавим плагину конфигурационное свойство allowedDecimalSeparators, конструктор, устанавливающий при необходимости значение этого свойства по умолчанию, а также саму обработку в методе filterKeys().
Теперь любой из поддерживаемых разделителей будет заменяться на правильный. Однако, разделителей можно ввести куда и сколько угодно. Исправим сие недоразумение.
Под поддержкой псевдомаски будем понимать невозможность введения недопустимого символа, а также допустимого символа в недопустимое место. Например, знак минус "-" не в начале числа, или несколько десятичных разделителей в строке. Как было написано выше, фильтрация символов осуществляется через регулярное выражение. Однако для поддержки маски ввода нужно нечто большее, поэтому добавим полю ещё один метод checkValue(), который будет принимать в качестве аргумента новый введенный символ и проверять получаемое значение на соответствие псевдомаске.
Условно строковое представление числового значения можно разбить на подстроки «до» и «после» десятичного разделителя. Так и будем проверять:
Приведенный код достаточно подробно прокомментирован, неясным остаются intRe и decRe. Это — регулярные выражения для проверки целой и дробной части числа соответственно, которые будут формироваться при подключении плагина в ещё одном добавленном полю методе updateDecimalPrecision().
Описанный метод надо вызвать у поля в конце метода init() создаваемого плагина.
Полученный плагин вполне работоспособен, однако, не лишен некоторых неприятных особенностей: например, введенное значение, десятичная часть которого полностью занимает отведенные ей разряды, нельзя удалить, выделив его. Для борьбы с таким положением дел немного усовершенствуем код checkValue(), добавив обработку выделенной части текста в поле. В результате код будет выглядеть вот так
На этом все. На всякий случай привожу полный код плагина и демонстрационного примера.
Начнем сначала, или как устроена фильтрация в стандартном 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(); });
10.02.2012 21:40+0400