ExtJS - キーボードでボタンのクリックをするプラグイン

この記事は [Sencha Advent Calendar 2014](http://www.adventar.org/calendars/490) 18日目の記事です。

---

キーボード入力でボタンを押せる
ExtJSのプラグインを作ってみようと思います。

## 要件

プラグインの要件を以下と定めます。

1. ファンクションキーの押下でボタンのクリックがされること
2. ボタンとキーの対応は自由に設定できること
3. ボタンがクリックできる条件下（表示・活性）でのみ動作すること

## 実装

簡単に作り方を解説していきます。

### 1. プラグインクラス定義

いつも通り*Ext.define*を使ってクラス定義をしていきます。  
extendは*Ext.plugin.Abstract*を指定します。
プラグインのベースクラスです。

これから作るプラグイン名は **BtnKeyMapper**とします（命名センスのなさはお察し）。

    Ext.define('Adventer.plugin.BtnKeyMapper', {

        // {{{ extend

        extend  : 'Ext.plugin.Abstract',

        // }}}
        // {{{ alias

        alias: 'plugin.btnkeymapper'

        // }}}

    });

これで何もしない空のプラグインがとりあえずできました。  

[前回](http://qiita.com/uyuta/items/de155a750824d60455b0)作ったフォームにボタンを置いて読み込んでみます。

      /* Form.js */

        // {{{ tbar

        tbar: [
            {
                text: '保存',
                plugins: [{
                    ptype: 'btnkeymapper'
                }]
            }
        ],

        // }}}


![スクリーンショット 2014-12-18 0.17.35.png](https://qiita-image-store.s3.amazonaws.com/0/30646/bc4da432-ae1b-fae5-c2bd-960d6b305102.png "スクリーンショット 2014-12-18 0.17.35.png")


もちろんまだ何も変わりません。
ここからが本番です。

### 2. initメソッド作成

initメソッドは、プラグインの起点になります。  
pluginを設定したコンポーネントが引数（ここでは *client*）として渡されます。
このclientになんやかんやしていきます。  
ちなみに、initが呼ばれるタイミングは コンポーネントの initComponentが呼ばれた後になります。レンダリング前ですね。


    /* plugin.BtnKeyMapper.js*/

    // {{{ init

    init: function (client) {
        var me = this;

        console.log(clinent);
    },

    // }}}


![スクリーンショット 2014-12-18 0.12.37.png](https://qiita-image-store.s3.amazonaws.com/0/30646/60ed1c70-6cd3-5133-d363-6838805f5a79.png "スクリーンショット 2014-12-18 0.12.37.png")

### 3. どのキーを割り当てるか？

この設定はボタン側でやりますね。  
とりあえずプラグインにコンフィグを渡す形を想定。

    /* Form.js*/
            {
                text: '保存',
                plugins: [{
                    ptype: 'btnkeymapper',
                    keyCode: Ext.event.Event.F1
                }]
            }

プラグイン側でとれるか確認。
アクセサーメソッドが欲しかったので、configに入れてinitConfigをコンストラクタで呼んでおきます。

    /* plugin.BtnKeyMapper.js*/

    // {{{ config

    config: {
        keyCode: null
    },

    // }}}
    // {{{ constructor

    constructor: function (config) {
        this.callParent(config);
        this.initConfig(config);
    },

    // }}}
    // {{{ init

    init: function (client) {
        var me = this;

        console.log('KeyCode:' + me.getKeyCode());

    // }}}

    },


![スクリーンショット 2014-12-18 0.26.02.png](https://qiita-image-store.s3.amazonaws.com/0/30646/1258ff88-2d8b-2c4d-813c-a1c5f9c237b0.png "スクリーンショット 2014-12-18 0.26.02.png")

無事とれたので、このキーコードのイベントをまず拾います。

### 4. キーイベントを拾う

キーイベントを拾うには *Ext.util.KeyMap*を使うと簡単です。  

    /* plugin.BtnKeyMapper.js*/

    // {{{ init
![スクリーンショット 2014-12-18 0.37.57.png](https://qiita-image-store.s3.amazonaws.com/0/30646/4e11c4f7-c5d1-5856-9827-bb46b50a4fb3.png "スクリーンショット 2014-12-18 0.37.57.png")

    init: function (client) {
        var me = this,
            keyCode = me.getKeyCode(),
            bind;

        bind = {
            target: document,
            key   : keyCode,
            fn    : me.doEmulate,
            defaultEventAction: 'stopEvent',
            scope : me
        };

        me.keyMap = new Ext.util.KeyMap(bind);

    },

    // }}}
    // {{{ doEmulate

    // キーイベントハンドラー
    doEmulate: function(keyCode, e) {
        var me = this;

        console.log('type : ' + keyCode);

    }

    // }}}


*Ext.util.KeyMap* にコンフィグオブジェクトを渡して、キーイベントと、呼び出す関数を設定します。

![スクリーンショット 2014-12-18 0.37.57.png](https://qiita-image-store.s3.amazonaws.com/0/30646/ecfad622-cac1-4a31-f7ef-7883e4c6c619.png "スクリーンショット 2014-12-18 0.37.57.png")

ひとまず、document全体のイベントですが拾えました。
この辺はあとで調整したい。

### 5. ボタンをクリックさせる

実はpluginは、*cmp* というプロパティに設定されたコンポーネントを持っています。
そいつに対してクリックの動作をしてやればOKです。

まずはクリックイベントのハンドラーを用意しておきます。
本来はViewControllerに書くべきですが、試しなのでViewに直接書いてしまいます。

    /* Form.js*/

    {
        text: '保存',
        plugins: [{
            ptype: 'btnkeymapper',
            keyCode: Ext.event.Event.F1
        }],
        listeners: {
            'click': function(btn) {
                Ext.Msg.alert(
                    'イベント',
                    '保存ボタンがクリックされました!'
                );
            }
        }
    }


そして、プラグイン側でクリックイベントに変換します。

    /* plugin.BtnKeyMapper.js*/

    // {{{ doEmulate

    // キーイベントハンドラー
    doEmulate: function(keyCode, e) {
        var me = this,
            button = me.cmp;// ボタン

        button.fireEvent('click', button, e);

    }

    // }}}

ボタンクリック・F1キー入力でこれが出ます。

![スクリーンショット 2014-12-18 0.49.37.png](https://qiita-image-store.s3.amazonaws.com/0/30646/ec97588d-181a-eba3-2668-bdbade4c4ec0.png "スクリーンショット 2014-12-18 0.49.37.png")

#### 問題発生!

    /* Form.js*/

    {
        text: '保存',
        plugins: [{
            ptype: 'btnkeymapper',
            keyCode: Ext.event.Event.F1
        }],
        handler: function(btn) {
            Ext.Msg.alert(
                'イベント',
                '保存ボタンがクリックされました!'
            );
        }
    }

これが動きませんでした。。

#### 修正!

    /* plugin.BtnKeyMapper.js*/

    // キーイベントハンドラー
    doEmulate: function(keyCode, e) {
        var me = this,
            button = me.cmp;// ボタン

        // TODO: 見た目変える

        // event発火/handler実行
        button.fireHandler(e);

    }

    // }}}

*fireHandler*でclickイベントの発火とhandlerの呼び出しが行われます。

#### 見た目

見た目上で押されたように見せるのはちょっと無理矢理ですが、
クラスの付け替えをdeferを使って行います。
ここはもう少しなんとかしたい所……

        // 見た目変える
        button.addCls('x-btn-pressed’);// 押された

        Ext.defer(function() {
            button.removeCls('x-btn-pressed’);// 離れた
        },300);

#### ここまでで

要件1と要件2はクリアかな。

### 6. ボタンの状態チェック

ボタンの状態チェックを入れます。 
以下２点をチェック。

1. ボタンが表示されていること
2. ボタンがenableであること

チェックは *doEmulate* の前でやります。
1の条件はこのボタンをもつコンテナーが表示されているかまで見ないといけません。
どこまで見るかは設定できるようにしましょうか。

*parentQuery*　というコンフィグで設定できるようにします。

    /* Form.js*/

            {
                text: '保存',
                plugins: [{
                    ptype: 'btnkeymapper',
                    keyCode: Ext.event.Event.F1,
                    parentQuery: 'form'
                }],
                handler: function(btn) {

プラグイン側ではそのクエリをもとに親コンテナの状態を確認します。

    /* plugin.BtnKeyMapper.js*/

    // {{{ config

    config: {
        keyCode: null,
        parentQuery: 'container'
    },

    // }}}
    …
    // {{{ doEmulate

    // キーイベントハンドラー
    doEmulate: function(keyCode, e) {
        var me = this,
            button = me.cmp;// ボタン

        if (!me.checkBtnEnable(button, keyCode, e)) {
            return false;
        }

        // 見た目変える
        button.addCls('x-btn-pressed');

        Ext.defer(function() {
            button.removeCls('x-btn-pressed');
        },300);

        // event発火/handler実行
        button.fireHandler(e);

    },

    // }}}
    // {{{ checkBtnEnable

    checkBtnEnable: function(button, keyCode, e) {
        var me              = this,
            parent          = button.up(me.getParentQuery()),
            parentIsVisible = true,
            clickable       = true;

        // 親コンテナーの状態チェック
        parentIsVisible = parent.isVisible();

        // 表示状態 かつ 活性状態であること
        clickable = parentIsVisible && button.isVisible() && !button.disabled;

        return clickable;
    }

    // }}}

要件3 もクリアです!

### 7. お掃除

いろいろお掃除します。  
ボタンが破棄されたらイベントハンドラも破棄したりとか。

以下、全コードです。

    Ext.define('Adventer.plugin.BtnKeyMapper', {

        // {{{ extend

        extend  : 'Ext.plugin.Abstract',

        // }}}
        // {{{ alias

        alias: 'plugin.btnkeymapper',

        // }}}
        // {{{ mixins

        mixins: {
            observable: 'Ext.util.Observable'
        },

        // }}}
        // {{{ requires

        requires: [
            'Ext.util.KeyMap'
        ],

        // }}}
        // {{{ config

        config: {
            /**
            * @cfg {Number} keyCode
            * 監視するキーイベント
            * @accessor
            */
            keyCode: null,

            /**
            * @cfg {String} parentQuery
            * ボタンが表示状態かを確認する親コンテナ
            * @accessor
            */
            parentQuery: 'container'
        },

        // }}}
        // {{{ constructor

        constructor: function (config) {
            this.callParent(config);
            this.mixins.observable.constructor.call(this, config);
            this.initConfig(config);
        },

        // }}}
        // {{{ init

        init: function (client) {
            var me = this,
                bind;

            // ボタンがレンダリングされている間だけ実行するようにする
            me.mon(client, {
                destroy    : me.stopEmulation,
                afterrender: me.startEmulation,
                scope: me
            });

        },

        // }}}
        // {{{ startEmulation

        startEmulation: function(button) {
            var me = this,
                keyCode = me.getKeyCode(),
                bind   = {
                    target: document,
                    key   : keyCode,
                    fn    : me.doEmulate,
                    defaultEventAction: 'stopEvent',
                    scope : me
                };

            me.keyMap = new Ext.util.KeyMap(bind);

        },

        // }}}
        // {{{ stopEmulation

        stopEmulation: function(button) {
            var me = this;
            // keyイベント削除
            me.keyMap.destroy();
        },

        // }}}
        // {{{ doEmulate

        // キーイベントハンドラー
        doEmulate: function(keyCode, e) {
            var me = this,
                button = me.cmp;// ボタン

            if (!me.checkBtnEnable(button, keyCode, e)) {
                return false;
            }

            // 押されて
            button.addCls('x-btn-pressed');
            // 離す
            Ext.defer(function() {
                button.removeCls('x-btn-pressed');
            },300);

            // event発火/handler実行
            button.fireHandler(e);

        },

        // }}}
        // {{{ checkBtnEnable

        checkBtnEnable: function(button, keyCode, e) {
            var me              = this,
                parent          = button.up(me.getParentQuery()),
                parentIsVisible = true,
                clickable       = true;

            // 親コンテナーの状態チェック
            parentIsVisible = parent.isVisible();
            // 表示状態 かつ 活性状態であること
            clickable = parentIsVisible && button.isVisible() && !button.disabled;

            return clickable;
        }

        // }}}


    });


## まとめ

+ pluginは *Ext.plugin.Abstract* を継承して作ります。
+ plugin内で *this.cmp* に対してなんやかんやして作っていきます。
+ keyイベントを拾うのは *Ext.util.KeyMap* を使うと楽。

作る過程を書いていった結果、無駄に長くなってしまいました。反省。
コードの無駄も多いのでまだまだ調整の必要ありですがご勘弁を。

明日は toshimitsusato さんです。