Pull to refresh

Фильтрация вводимых символов в Ext.form.field.Number

Reading time11 min
Views6.7K
Думаю, каждый, кто писал 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();
});


UPD: Как заставить заработать фильтрацию в firefox > 12. Реализация взята отсюда, как определить версию firefox, думаю, никому не нужно показывать. Если с этим есть проблемы можно посмотреть тут. Итак, кусок кода из filterKeys:
...
} else if (Ext.isGecko) {
    // для gecko-движка тормозим событие
    e.stopEvent();
    // https://bugzilla.mozilla.org/show_bug.cgi?id=749185
    // переменная firefoxVersion определена заранее.
    if (firefoxVersion < 12) {
        // создаем новое событие с измененным кодом нажатой клавиши
        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);
    } else if (this.checkValue(charCode)) {
        // http://forums.mozillazine.org/viewtopic.php?p=12198605&sid=3723622be9117f663d16d522fe03deb5#p12198605
        var tgt = e.getTarget();
        if ('selectionStart' in tgt) {
            if (tgt.selectionStart == tgt.textLength) {
                tgt.value += charCode;
            } else {
                var lastpos = tgt.selectionStart;
                tgt.value = tgt.value.substr(0, lastpos) + charCode + tgt.value.substr(lastpos);
                tgt.selectionStart = lastpos + 1;
                tgt.selectionEnd = lastpos + 1;
            }
        }
    }
    return;
} else if (Ext.isWebKit) {
...
Tags:
Hubs:
Total votes 21: ↑18 and ↓3+15
Comments5

Articles