IE系でinputイベントが発火しないのをなんとかしてみる（ついでにIME入力中は発火しないイベントも追加）

最初のコードはinputやtextareaでの値の変更時にIME入力中でなければ独自のイベントを発火するというものでしたが、IE系では「contenteditable="true"」を設定したエレメントでinputイベントが発火しないという他のブラウザとの差異がありましたので全面的に書き直しました。
最初のコードも旧コードとして残しておきます。

# はじめに
以前[jQueryでIME入力確定時にイベントを発行する](http://qiita.com/hrdaya/items/6488d8dd3962cf35c0a0)という記事を投稿しましたが、~~inputやtextarea~~　inputやtextarea、「contenteditable="true"」を設定したエレメントでの値の変更を監視するという面では不十分だった為、新たに書き直したものを公開します。
なお、2016年1月13日以降はWindowsVistaはIE9、その他はIE11のサポートのみとなる為、今回作成したものはIE9以降のサポートとし、onInputイベントを中心として作成しました。
onInputが発火しない部分についてはdocumentのselectionchangeイベント、focus、blur、keyup、dropを監視して擬似的にinput、input2イベントを発火させています。
inputイベント発火時にIME入力中でない場合はmcinputというイベントを擬似的に発火させています。

## 参考サイト
- [HTMLテキストフィールド入力値に対する日本語 IME のハンドリング](http://qiita.com/kumatch/items/8341fd2d83a146277413)
- [リアルタイム入力チェック(inputイベント)のクロスブラウザコード(polyfill?)の例](http://qiita.com/sounisi5011/items/767392c1f7736e2e5d4c)

## IE系でonInputイベントが発生しないタイミング

### IE9
1. BackSpace、Deleteキーでの文字の削除
2. 「切り取り」「Ctrl+x」
3. 「元に戻す」「Ctrl+z」
4. 選択した範囲を別のエレメントにドロップして内容が変わったとき

### IE全般
1. contenteditable="true"を設定したエレメント
2. ESCキーで入力文字列が取り消されたとき
3. IME入力終了時

## 検証環境
Windows7 64bitの

- IE11
- IE9（IE11でエミュレーション）
- FireFox
- Chrome

にて検証を行っています。

# コード
- [Github-jQuery.mcInputEvent](https://github.com/hrdaya/jQuery.mcInputEvent)

```js
(function (factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define(['jquery'], factory);
    } else if (typeof module === 'object' && module.exports) {
        // Node/CommonJS
        module.exports = factory(require('jquery'));
    } else {
        // Browser globals
        factory(jQuery);
    }
}(function ($) {
    'use strict';
    // プラグイン名
    var pluginName = 'mcInputEvent';
    // IME入力中以外のイベント名
    var notImeEvent = 'mcinput';
    // inputに変わるイベント名
    var inputEvent = 'input2';
    // Microsoftのブラウザかどうか
    var isMsBrowser = false;
    // IE9かどうか
    var isIE9 = false;
    // ブラウザ判定
    // EdgeをMicrosoft系のブラウザとして入れているが、
    // WebKitと動作が異なる部分は修正すべきバグと
    // http://blogs.windows.com/msedgedev/2015/06/17/building-a-more-interoperable-web-with-microsoft-edge/
    // でも述べている為、将来的には削除する必要があるかも
    var ua = window.navigator.userAgent.toLowerCase();
    if (ua.indexOf('msie') > -1 ||
            ua.indexOf('trident') > -1 ||
            ua.indexOf('edge') > -1) {
        isMsBrowser = true;
        if (ua.indexOf('msie 9.0') > -1) {
            isIE9 = true;
        }
    }

    // プラグイン本体
    var Plugin = function (elm) {
        this.$elm = $(elm);
        // IME入力中かどうか
        this.isComposition = false;
        // タイマー用
        this.timer;
        // イベント発火時にFunctionに渡す値
        // contenteditableに対応する為にタグのタイプをとりあえずhtmlにする
        this.obj = {
            lastVal: '',
            tagType: 'html'
        };
        // イベントの種類
        this.checkEvents = [
            'input.' + pluginName,
            'compositionstart.' + pluginName,
            'compositionend.' + pluginName
        ];
        // 初期化処理
        this.init();
    };
    // プラグインのプロトタイプ
    Plugin.prototype = {
        // プラグインのイベント有効化
        on: function () {
            // イベントの重複登録を避けるため一旦off
            this.off();
            // inputとIME入力判定用のイベントを有効にする
            this.$elm.on(this.checkEvents.join(' '), this.setEvents.bind(this));
        },
        // プラグインのイベント無効化
        off: function () {
            // このプラグインのイベントとプラグイン名クラスを持ったイベントを無効化
            this.$elm.off('.' + pluginName);
            $(document).off('.' + pluginName);
        },
        // プラグインの破棄
        destroy: function () {
            // プラグインのイベント無効化
            this.off();
            // エレメントからプラグイン用のデータを削除
            this.$elm.removeData(pluginName);
        },
        // イベントの強制発火
        triggerEvent: function () {
            // inputを発火させることでinput2,mcinputも発火させる
            // 合わせてエレメント内の最終値を更新させる
            this.$elm.trigger('input');
        },
        // 初期化処理
        init: function () {
            // input、textareaの時はタグのタイプをinputに変更
            var tagName = this.$elm.prop('tagName').toLowerCase();
            if (tagName === 'input' || tagName === 'textarea') {
                this.obj.tagType = 'input';
                // IE9のバグ対策の為、selectionchangeをベースに入力の変更を監視する
                if (isIE9) {
                    this.checkEvents.push('focus.' + pluginName);
                    this.checkEvents.push('blur.' + pluginName);
                }
            } else {
                // input、textarea以外の時にMicrosoft系ブラウザの場合はinputイベントが発火しない為
                // selectionchangeをベースに入力の変更を監視する
                if (isMsBrowser) {
                    this.checkEvents.push('focus.' + pluginName);
                    this.checkEvents.push('blur.' + pluginName);
                }
            }
            // keyup → ESCでの文字削除対策、input、textarea以外での入力対策
            // drop → 値がドロップされた時の対策
            if (isMsBrowser) {
                this.checkEvents.push('keyup.' + pluginName);
                this.checkEvents.push('drop.' + pluginName);
            }
            // 現在の値を最終値としてセット
            this.setLastVal();
        },
        // onにした時に各イベントをセットする
        setEvents: function (e) {
            // イベントのタイプごとに処理
            switch (e.type) {
                case 'input':
                    // 最終入力値をセットする
                    this.setLastVal();
                    // inputの代わりのイベントを発火
                    this.triggerInput2();
                    // IME入力時以外にプラグインイベントを発火
                    if (!this.isComposition) {
                        this.triggerMcInput();
                    }
                    break;
                case 'compositionstart':
                    // IME入力中フラグのセット
                    this.isComposition = true;
                    break;
                case 'compositionend':
                    // IME入力中フラグのリセット
                    this.isComposition = false;
                    // MS系ブラウザでIME入力後にイベントが発火しない事への対策
                    // 他ブラウザと合わせるために値が同じでも発火させる
                    if (isMsBrowser) {
                        this.triggerEvent();
                    }
                    break;
                case 'focus':
                    // フォーカスを受けた時にselectionchangeイベントを有効にする
                    $(document).on('selectionchange.' + pluginName, this.triggerInput.bind(this));
                    break;
                case 'blur':
                    // フォーカスが外れた時にselectionchangeイベントを無効にする
                    $(document).off('selectionchange.' + pluginName);
                    // 選択範囲を別のエレメントに移動する時の対策
                    this.setDelayInputEvent();
                    break;
                case 'keyup':
                    // 擬似的にinputイベントを発火する
                    this.triggerInput();
                    break;
                case 'drop':
                    // 擬似的にinputイベントを発火する
                    this.setDelayInputEvent();
                    break;
            }
        },
        // mcinputイベントを発火する
        triggerMcInput: function () {
            // イベントオブジェクトと共にプラグインイベントを発火する
            this.$elm.trigger($.Event(notImeEvent, this.obj));
        },
        // input2イベントを発火する
        triggerInput2: function () {
            // イベントオブジェクトと共にプラグインイベントを発火する
            this.$elm.trigger($.Event(inputEvent, this.obj));
        },
        // 最終入力値と同じかどうかを判定してinputイベントを発火する
        triggerInput: function () {
            if (!this.isSameVal()) {
                this.$elm.trigger('input');
            }
        },
        // 遅れてイベントを発火する
        setDelayInputEvent: function () {
            clearTimeout(this.timer);
            this.timer = setTimeout(this.triggerInput.bind(this), 50);
        },
        // 入力前の値と現在の値が同じか確認する
        isSameVal: function () {
            return this.obj.tagType === 'input' ?
                    this.obj.lastVal === this.$elm.val() :
                    this.obj.lastVal === this.$elm.html();
        },
        // 最終入力値をセットする
        setLastVal: function () {
            this.obj.lastVal = this.obj.tagType === 'input' ?
                    this.$elm.val() :
                    this.$elm.html();
        }
    };
    // プラグインの実行
    $.fn[pluginName] = function (method) {
        // CompositionEventをサポートする時のみ実行
        if (window.hasOwnProperty('CompositionEvent')) {
            // デフォルトのメソッドをonにする
            method = method || 'on';
            // エレメントごとにループする
            this.each(function () {
                // データ属性の取得
                var data = $.data(this, pluginName) ||
                        $.data(this, pluginName, new Plugin(this));
                // プロトタイプの関数に引数が存在する場合は関数の実行
                switch (method) {
                    // 有効化(on)、無効化(off)、破棄(destroy)、強制発火(triggerEvent)
                    case 'on':
                    case 'off':
                    case 'destroy':
                    case 'triggerEvent':
                        data[method]();
                        break;
                }
            });
        }
        return this;
    };
}));
```

## コード適用方法
```js
$(function () {
    // エレメントにプラグインの適用
    $(element).mcInputEvent();

    // プラグインの一時停止
    $(element).mcInputEvent('off');

    // プラグインの再適用
    $(element).mcInputEvent('on');

    // プラグインの破棄
    $(element).mcInputEvent('destroy');

    // イベントの強制発火(input、input2、mcinput)
    $(element).mcInputEvent('triggerEvent');

    // IME入力時以外で値が変わった時のイベント取得
    $(element).on('mcinput', function (e) {
        // ここに処理
        
        // input、textareaかどうかの判定
        e.tagType === 'input' // input、textarea
        e.tagType === 'html'  // input、textarea以外

        // エレメントの値取得
        e.lastVal
        // e.lastVal === 'input'の時は$(this).val();と同じ
        // e.lastVal === 'html'の時は$(this).html();と同じ
    });

    // inputと同じタイミングで発火するinput2イベントの取得
    // inputではe.tagType、e.lastValを取得できないためinput2を使用する
    $(element).on('input2', function (e) {
        // ここに処理
        
        // input、textareaかcontenteditable="true"を設定したエレメントかの判定
        e.tagType === 'input' // input、textarea
        e.tagType === 'html'  // input、textarea以外

        // エレメントの値取得
        e.lastVal
        // e.lastVal === 'input'の時は$(this).val();と同じ
        // e.lastVal === 'html'の時は$(this).html();と同じ
    });
});
```




# 旧コード（input、textareaのみでIME入力中以外にmcinputイベントを発火）
```js
(function (factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define(['jquery'], factory);
    } else if (typeof module === 'object' && module.exports) {
        // Node/CommonJS
        module.exports = factory(require('jquery'));
    } else {
        // Browser globals
        factory(jQuery);
    }
}(function ($) {
    'use strict';
    // プラグイン名
    var pluginName = 'mcInputEvent';

    // イベント名
    var eventName = 'mcinput';

    // 捕捉するキーイベント
    var keyEvents = [
        'input.' + pluginName,
        'compositionstart.' + pluginName,
        'compositionend.' + pluginName
    ];

    // Microsoftのブラウザかどうか
    var isMsBrowser = false;

    // ブラウザ判定
    var ua = window.navigator.userAgent.toLowerCase();
    var ver = window.navigator.appVersion.toLowerCase();
    if (ua.indexOf('msie') !== -1) {
        isMsBrowser = true;
        if (ver.indexOf('msie 9.') !== -1) {
            // IE9の時はキーイベントを追加する
            keyEvents.push('focus.' + pluginName);
            keyEvents.push('blur.' + pluginName);
        }
    } else if (ua.indexOf('Edge') > -1 || ua.indexOf('trident') !== -1) {
        isMsBrowser = true;
    }

    // プラグイン本体
    var Plugin = function (elm) {
        this.$elm = $(elm);

        this.isComposition = false;
        this.lastVal = '';
    };

    // プラグインのプロトタイプ
    Plugin.prototype = {
        // プラグインのイベント有効化
        on: function () {
            var _this = this;

            // イベントの重複登録を避けるため一旦off
            _this.off();

            // イベントの登録
            _this.$elm.on(keyEvents.join(' '), function (e) {
                // イベントのタイプごとに処理
                switch (e.type) {
                    case 'input':
                        // IME入力中でない時にプラグインイベントを発火
                        if (!_this.isComposition) {
                            _this.fireEvent();
                        }
                        break;
                    case 'compositionstart':
                        // 入力中フラグのセット
                        _this.isComposition = true;
                        break;
                    case 'compositionend':
                        // 入力中フラグのリセット
                        _this.isComposition = false;
                        // IME入力完了時にプラグインイベントを発火
                        // MS系ブラウザではinput→compositionendの順に発火することへの対策
                        if (isMsBrowser) {
                            _this.fireEvent();
                        }
                        break;
                        // ここからIE9のonInputイベントのバグに対する処理
                    case 'focus':
                        // フォーカスを受け取ったときにdocumentのselectionchangeイベントを有効にする
                        $(document).on('selectionchange.' + pluginName, function () {
                            // IME入力時以外で入力前の値と異なる場合
                            if (!_this.isComposition && !_this.isSameVal()) {
                                // 更新された値を最終値にセットする
                                _this.setLastVal();
                                // プラグインイベントを発火
                                _this.fireEvent();
                            }
                        });
                        // 現在の値を最終入力の値としてセット
                        _this.setLastVal();
                        break;
                    case 'blur':
                        // フォーカスが外れたときにdocumentのselectionchangeイベントを無効にする
                        $(document).off('selectionchange.' + pluginName);
                        break;
                }
            });
        },
        // プラグインのイベント無効化
        off: function () {
            this.$elm.off($.Event(eventName));
            this.$elm.off('.' + pluginName);
            $(document).off('.' + pluginName);
        },
        // プラグインの破棄
        destroy: function () {
            this.off();
            this.$elm.removeData(pluginName);
        },
        // イベントの発行
        fireEvent: function () {
            this.$elm.trigger($.Event(eventName));
        },
        // 入力前の値と現在の値が同じか確認する
        isSameVal: function () {
            return this.lastVal === this.$elm.val();
        },
        // 最終入力値をセットする
        setLastVal: function () {
            this.lastVal = this.$elm.val();
        }
    };

    // プラグインの実行
    $.fn[pluginName] = function (method) {
        // CompositionEventをサポートする時のみ実行
        if ('CompositionEvent' in window) {
            // デフォルトのメソッドをonにする
            method = method || 'on';

            // エレメントごとにループする
            this.each(function () {
                // 文字列入力エリア以外には適用しない
                switch ($(this).prop('tagName').toLowerCase()) {
                    case 'input':
                        switch ($(this).prop('type').toLowerCase()) {
                            case 'text':
                            case 'password':
                            case 'search':
                            case 'tel':
                            case 'url':
                            case 'email':
                            case 'date':
                            case 'time':
                            case 'number':
                            case 'range':
                            case 'color':
                            case 'datetime':
                            case 'datetime-local':
                            case 'month':
                            case 'week':
                                break;
                            default:
                                return true;
                        }
                    case 'textarea':
                        break;
                    default:
                        return true;
                }
                // データ属性の取得
                var data = $.data(this, pluginName) ||
                        $.data(this, pluginName, new Plugin(this));
                // プロトタイプの関数に引数が存在する場合は関数の実行
                switch (method) {
                    case 'on':
                    case 'off':
                    case 'destroy':
                        data[method]();
                        break;
                }
            });
        }
        return this;
    };
}));
```

## 旧コード適用方法
```js
$(function () {
    // エレメントにプラグインの適用
    $(element).mcInputEvent();

    // イベントの登録
    $(element).on('mcinput', function () {
        // ここに処理
    });

    // プラグインの一時停止
    $(element).mcInputEvent('off');

    // プラグインの再適用
    $(element).mcInputEvent('on');

    // プラグインの破棄
    $(element).mcInputEvent('destroy');
});
```

## 旧コード検証内容
| | IE11 | IE9 | FireFox | Chrome |
|:-----------|:-----------:|:-----------:|:-----------:|:-----------:|
|直接入力|○|○|○|○|
|Ctrl+x|○|○|○|○|
|Ctrl+v|○|○|○|○|
|Ctrl+z|○|○|○|○|
|カット（コンテキスト）|○|○|○|○|
|ペースト（コンテキスト）|○|○|○|○|
|元に戻す（コンテキスト）|○|○|○|○|
|入力エリアにドロップ|○|○|○|○|
|IME入力中|-|-|-|-|
|IME入力確定|○|○|○|○|
|BS・ESC等での入力取消し|○|○|○|○|
|IME入力中にFocusの喪失|○|2回|○|○|
|Focus取得|△|-|-|-|
|Focus喪失|△|△|-|-|
|入力値の再変換中|-|-|-|-|
|入力値の再変換確定|○|○|○|○|
※IE11ではFocus取得・喪失時に入力エリアが空の場合にイベントが発火します。
※IE9ではIME入力中にFocusを喪失すると2回イベントが発火します。これは通常のIME入力確定のイベントと下記の入力値が変わった時のFocus喪失によるイベントの発火が起こるためです。
※IE9では入力値が変わったときにFocusを喪失するとイベントが発火します。
※上記の検証には含まれていませんが、IE9では入力値が変わった後に入力エリアをクリックするとイベントが発火します。
※上記の検証には含まれていませんが、例えば「沖縄県那覇市」と入力する場合にまず「おきなわけん」と入力し、変換して「沖縄県」と候補を出し、エンターキーで入力値を確定しないで続けて「なはし」と入力した場合に、入力エリア上では「沖縄県」が確定しますが、その時にIE系ではイベントは発火しませんが、IE以外ではイベントが発火します。IEではイベントが発火しませんが、「なはし」の入力の確定・取消し時にイベントが発火しますので、実用上は問題が無いと考えます。


# 最後に
このプラグインを利用する方は入力値の変換や検証に使用されるかと思いますが、クライアントサイドでの変換、検証とサーバサイドでの変換、検証は全くの別物と考えてください。
クライアントサイドで変換や検証が済んでいるからといってサーバサイドでの変換、検証が不要に無くなるという事は絶対にありません。
サーバへアクセスしてくるのは必ずしもブラウザからでは無いということを理解しておいてください。 :neutral_face: