Яндекс.Метрика

    Песочница

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

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