ブラウザ上でフリック入力ができる仮想キーボードを作ってみた


# はじめに
先日、[対戦型ポケモンWordle](https://qiita.com/YottyPG/items/c4488fef8ad09024a181)をリリースしました。
リリース当初、ただテキストボックスにポケモンの名前を入力する形にしており、入力履歴の管理ができておりませんでした。
入力履歴の機能を追加しようと、大元のWordleを参考に入力用のボタンを用意しようと思いました。
ただ、日本語は50音＋濁点・半濁点で80ボタン以上必要で、このボタンからポチポチするのは入力が大変だろうなと思いました。
そこで、ブラウザ上でフリック入力できる仮想キーボードを作ってみました。

# 完成品

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/ebf3cc7c-f90f-12e7-695f-b1c3fde2ae76.gif">

<p class="codepen" data-height="479" data-default-tab="result" data-slug-hash="eYerGad" data-user="yotty" style="height: 479px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/yotty/pen/eYerGad">
  フリック入力</a> by yotty (<a href="https://codepen.io/yotty">@yotty</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

パソコンからでは直接フリック機能が使えません。
パソコンからフリック機能を確認したい方は、デベロッパーツールでデバイスを変更することで試すことができます。

※Chromeの場合
F12でデベロッパーツールを開き、以下のアイコンからデバイスをモバイル端末に変更
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/7b0a6856-e86a-a682-0df9-b845e62bc1fd.png)


# 実装
以下、実装内容の解説です。

## 事前準備

事前準備としてさくっと外観を作っておきます。
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/f64a62c9-66c5-4341-8f58-25ea30228903.png">


また、フリック入力の際、長押しで文字選択されたり、入力のタップでズームされたりしないように以下を追加しておきます。

```css:css
body{
    user-select: none;
    -webkit-user-select: none;
    -ms-user-select: none;
    -moz-user-select: none;
    -khtml-user-select: none;
    touch-action: none;
}
```

さらに、フリックした際、濁点・半濁点の切り替えを行った際に入力すべき文字を特定するために以下のような配列を用意しておきます。

```js
const l_word = [
    ['ア', 'イ', 'ウ', 'エ', 'オ' ]
    , ['カ', 'キ', 'ク', 'ケ', 'コ' ]
    , ['サ', 'シ', 'ス', 'セ', 'ソ' ]
    , ['タ', 'チ', 'ツ', 'テ', 'ト' ]
    , ['ナ', 'ニ', 'ヌ', 'ネ', 'ノ' ]
    , ['ハ', 'ヒ', 'フ', 'ヘ', 'ホ' ]
    , ['マ', 'ミ', 'ム', 'メ', 'モ' ]
    , ['ヤ', '', 'ユ', '', 'ヨ' ]
    , ['ラ', 'リ', 'ル', 'レ', 'ロ' ]
    , ['ワ', 'ヲ', 'ン', 'ー', '' ]
];

const l_word_switch = [
    ['ア', 'ァ']
    , ['イ', 'ィ']
    , ['ウ', 'ゥ']
    , ['エ', 'ェ']
    , ['オ', 'ォ']
    , ['カ', 'ガ']
    , ['キ', 'ギ']
    , ['ク', 'グ']
    , ['ケ', 'ゲ']
    , ['コ', 'ゴ']
    , ['サ', 'ザ']
    , ['シ', 'ジ']
    , ['ス', 'ズ']
    , ['セ', 'ゼ']
    , ['ソ', 'ゾ']
    , ['タ', 'ダ']
    , ['チ', 'ヂ']
    , ['ツ', 'ッ', 'ヅ']
    , ['テ', 'デ']
    , ['ト', 'ド']
    , ['ハ', 'バ', 'パ']
    , ['ヒ', 'ビ', 'ピ']
    , ['フ', 'ブ', 'プ']
    , ['ヘ', 'ベ', 'ペ']
    , ['ホ', 'ボ', 'ポ']
    , ['ヤ', 'ャ']
    , ['ユ', 'ュ']
    , ['ヨ', 'ョ']
];
```

## フリック入力
フリックを検知するために、「touchstart」「touchmove」「touchend」の3つのイベントを利用します。

「touchstart」で、タッチした場所を記憶しておきます。
その他、フリックしたときのオブジェクトの位置や文字などの設定もしています。
```js:touchstart
function onTouchStart(event) {
    $(event.currentTarget).addClass('gray_for_touch');

    position = event.originalEvent.touches[0];
    direction = 'c';

    //フリックのオブジェクトの位置を移動させておく
    offset = $(event.currentTarget).offset();
    $('.kb_key_u').offset({ top: offset.top - 48, left: offset.left });
    $('.kb_key_d').offset({ top: offset.top + 48, left: offset.left });
    $('.kb_key_l').offset({ top: offset.top, left: offset.left - 48 });
    $('.kb_key_r').offset({ top: offset.top, left: offset.left + 48 });

    //テキストセット
    var l = l_word.find(x => x.includes($(event.currentTarget).text()));
    $('.kb_key_u').text(l[2]);
    $('.kb_key_d').text(l[4]);
    $('.kb_key_l').text(l[1]);
    $('.kb_key_r').text(l[3]);
};
```

「touchmove」では、移動した現在の位置を取得して上下左右にどれだけ移動したかを計算します。
上下左右それぞれの移動距離のmaxを取って移動方向を検知し、閾値20を超えたらその移動方向にフリックしたと判定させています。
```js:touchmove
function onTouchMove(event) {
    direction = 'c';
    $('.kb_key_u').addClass('transparent');
    $('.kb_key_d').addClass('transparent');
    $('.kb_key_l').addClass('transparent');
    $('.kb_key_r').addClass('transparent');
    $(event.currentTarget).addClass('gray_for_touch');
    $(event.currentTarget).removeClass('transparent');

    var new_position = event.originalEvent.touches[0];
    
    var u = position.screenY - new_position.screenY;
    var d = new_position.screenY - position.screenY;
    var l = position.screenX - new_position.screenX;
    var r = new_position.screenX - position.screenX;
    var max = Math.max(u, d, r, l);

    if (max < 20) {
        return;
    }

    var input_key = $(event.currentTarget).text();

    $(event.currentTarget).removeClass('gray_for_touch');
    $(event.currentTarget).addClass('transparent');

    if (max == u) {
        direction = 'u';
        $('.kb_key_u').removeClass('transparent');
    }
    else if (max == d) {
        direction = 'd';
        $('.kb_key_d').removeClass('transparent');
    }
    else if (max == l) {
        direction = 'l';
        $('.kb_key_l').removeClass('transparent');
    }
    else if (max == r) {
        direction = 'r';
        $('.kb_key_r').removeClass('transparent');
    }
};
```


「touchend」では、「touchmove」で検知したフリックの方向から入力すべき文字を判断します。
フリックの方向によってindexを変えて、事前に用意した「l_word 」から入力する文字を決定します。
あとは、テキストボックスの値の最後にその文字を付与します。


```js:touchend
function onTouchEnd(event) {
    $('.kb_key_u').addClass('transparent');
    $('.kb_key_d').addClass('transparent');
    $('.kb_key_l').addClass('transparent');
    $('.kb_key_r').addClass('transparent');
    $(event.currentTarget).removeClass('gray_for_touch');
    $(event.currentTarget).removeClass('transparent');

    var input_key = '';
    var key = $(event.currentTarget).text();
    
    var l = l_word.find(x => x.includes($(event.currentTarget).text()));
    var index = 0;
    
    if (direction == 'c') {
        index = 0;
    }
    else if (direction == 'u') {
        index = 2;
    }
    else if (direction == 'd') {
        index = 4;
    }
    else if (direction == 'l') {
        index = 1;
    }
    else if (direction == 'r') {
        index = 3;
    }

    var txt = $('#txt').val();
    if (l[index] == '') {
        return;
    }

    $('#txt').val(txt + l[index]);
};
```


## 濁点・半濁点切り替え

テキストボックスに入力されている最後の文字を「l_word_switch」から探し、indexを1ずらして濁点・半濁点を切り替えて文字を取得しています。

```js:濁点・半濁点切り替え
function onTouchStart_switch(event) {
    $(event.currentTarget).addClass('gray_for_touch');
    temp_word = $('#txt').val();
    targer_word = temp_word.slice(-1);
    var l_switch = l_word_switch.find(x => x.includes(targer_word));
    if (l_switch === undefined) {
        return;
    }

    var index = l_switch.indexOf(targer_word);
    if (index < 0) {
        return;
    }
    else if (index == l_switch.length - 1) {
        index = 0;
    }
    else {
        index += 1;
    }
    
    input_key = l_switch[index];
    $('#txt').val(temp_word.slice(0, -1) + input_key);
};
```

## バックスペース
テキストボックスの最後の文字を削除するだけで実装できます。

```js:バックスペース
$(document).on('touchstart', '#kb_key_bs', function (event) {
    $(event.currentTarget).addClass('gray_for_touch');
    var temp_txt = $('#txt').val();
    $('#txt').val(temp_txt.slice(0, -1));
});
```

# おわりに
実際のアプリでは、今回紹介したフリック入力を組み込み、入力欄を細かく分けることによって入力履歴の管理を実現しています。
<img width="225" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/15ce018f-ab0c-7946-554e-c5dc110018bd.gif">

アプリが気になった方はこちらから遊ぶことができるので、ぜひ一度触ってみてください！

https://www.vs-pokemon-wordle.com/

# 参考

https://qiita.com/Wataru/items/a9548015e00683e142e4