Tabキーでインデントを挿入するChrome拡張

車輪の再発明感半端ないですが、作りました。

# 作ったもの
### 名前
Indent by Tab Key
### URL
https://chrome.google.com/webstore/detail/indent-by-tab-key/keokpaahghbmjbllhgoilmoihaapgjki
### 機能
QiitaおよびGitHubの
- textarea内でTabで押すとスペース2文字を挿入する
- textarea内でShift + Tabでインデントを2文字削る

# ロジックの説明
まずは、一番外側の部分から。

```js
const indent_size = 2;
const blanks = new Array(indent_size).fill(' ').join('');

let textareas = document.getElementsByTagName('textarea');
for (const textarea of textareas) {
    textarea.addEventListener('keydown', function(e) {
        if (e.keyCode !== 9) return;
        e.preventDefault();
        // 処理
    }
}
```
今後インデントの文字数を変えていけるように、blanksもindent_sizeを使って計算するようにしてあります。
（日頃rubyを使っているので、一度`' ' * indent_size`としようとしてしまいました。）
textarea内でkeydownされたときに、もしkeyCodeが9、つまりTabだったら、いろいろ処理をさせればよいです。当然カーソルが移動しないように`preventDefault()`するのを忘れずに。

続いて処理の部分ですが、その前に仕様について述べておくと、以下のように場合分けできます。

<table>
<thead>
    <tr>
        <th></th>
        <th>カーソルが当たっているだけ</th>
        <th>文字選択されている</th>
    </tr>
</thead>
<tbody>
    <tr>
        <th>Tab</th>
        <td>1. カーソルの位置にスペースを挿入</td>
        <td>2. 選択されている行にインデントを挿入</td>
    </tr>
    <tr>
        <th>Shift + Tab</th>
        <td colspan="2">3. 選択されている行のインデントを削除</td>
    </tr>
</tbody>
</table>

つまりこの3つの振り分けおよび、それぞれの処理を行えば機能が実現できるというわけです。


## 振り分け部分

```js
// addEventListner内なのでthisはtextareaです。textareaでよかった気がしてきた。
const sentence = this.value;
const selection_start = this.selectionStart;
const selection_end = this.selectionEnd;
if (!e.shiftKey && selection_start == selection_end) {
    // 1の処理
    return
}
const lines = sentence.split("\n");
let start_pos, end_pos;
[start_pos, end_pos] = start_and_end_line_num(lines, selection_start, selection_end); // 後で説明します
if (e.shiftKey) {
    // 3の処理
} else {
    // 2の処理
}
```

非常にシンプルです。
`selectionStart`と`seelectionEnd`はカーソルの選択箇所を示し、それらが等しいときは文字が選択されていない状態であることがわかります。
また、`e.shiftKey`でShiftが押されていたかもわかるので、それらを組み合わせることで場合分けをすることができます。

<hr>
`selectionStart`や`seelectionEnd`の例
 
```
123
456
```

のようなtextareaがあったときに、

- 1の左にカーソルがあるとき -> `selectionStart: 0, selectionEnd: 0`
- 23を選択 -> `selectionStart: 1, selectionEnd: 3`
- 234を選択 -> `selectionStart: 1, selectionEnd: 5` (3と4の間の\nが含まれています。)

というように値がとれます。

<hr>

## 1. カーソルの位置にスペースを挿入
![ezgif.com-crop (2).gif](https://qiita-image-store.s3.amazonaws.com/0/141207/56aca8f9-8341-13e1-cb05-76dfa0822d04.gif)

動きとしてはこんな感じです。Atomを参考にしましたが、多くのエディタではこのような動作となっているのではないでしょうか。
こちらのロジックはとてもシンプルです。

```js
// 1の処理
let len = sentence.length;

this.value = sentence.substr(0, selection_start) + blanks + sentence.substr(selection_start, len);
this.selectionStart = selection_start + indent_size;
this.selectionEnd = selection_start + indent_size;
```

`this.selectionStart`および`this.selectionEnd`にindent_size足しているのは、カーソルに対し左側にスペースを挿入しているかのようにしたかったからで、こちらも一般的な動作ではないかと思います。
また、`this.selectionStart += indent_size;`としなかったのは、`this.value`を書き換えるときに、`this.selectionStart`も変わってしまう場合があったの、ローカル変数としてキャッシュした値を使うことにしています。

## 選択行番号等の取得
2の説明に入る前に、2, 3で両方必要な、どの行が選択されているのかというのを計算するロジックを示します。
振り分け部分のコードでこっそり出ていた、`start_and_end_line_num(lines, selection_start, selection_end)`がそれです。

```js
function start_and_end_line_num(lines, start, end) {
    if (start > end) return null;
    let start_pos;
    for (const [i, line] of lines.entries()) {
        if (start_pos === undefined && start <= line.length) {
            start_pos = [i, start];
        }
        if (end <= line.length + 1) {
            return [start_pos, [i, end]];
        }
        start -= line.length + 1;
        end -= line.length + 1;
    }
}
```

少し微妙なところはありますが、一応これで、何行目のどこから何行目のどこまで選択されているかがわかります。

## 2. 選択されている行にインデントを挿入
![ezgif.com-crop (1).gif](https://qiita-image-store.s3.amazonaws.com/0/141207/e36c7f56-67f4-8697-06f9-962f87dc876b.gif)


```js
this.value = lines.map(function(line, i) {
    return (start_pos[0] <= i && i <= end_pos[0]) ? blanks + line : line;
}).join("\n");
this.selectionStart = selection_start + indent_size;
this.selectionEnd = selection_end + indent_size * (end_pos[0] - start_pos[0] + 1);
```

各行について、選択行だったらblanksを行頭に足すだけです。
`this.selectionStart`や`this.selectionEnd`の調整も単純です。

## 3. 選択されている行のインデントを削除
![ezgif.com-crop.gif](https://qiita-image-store.s3.amazonaws.com/0/141207/aa9f520f-34f7-7330-edec-9e31d45aaff2.gif)

他の二つに比べて大分複雑になってしまいました。

```js
this.value = lines.map(function(line, i) {
    if (start_pos[0] <= i && i <= end_pos[0]) {
        let match = line.match(re);
        switch (i) {
            case start_pos[0]:
                start_deleted_blank_size = match[0].length;
                break;
            case end_pos[0]:
                end_deleted_blank_size = match[0].length;
                break;
            default:
                other_deleted_blank_size += match[0].length;
        }
        return line.replace(re, '');
    } else {
        return line;
    }
}
}).join("\n");
this.selectionStart = selection_start - Math.min(start_pos[1], start_deleted_blank_size);
if (start_pos[0] === end_pos[0]) {
    this.selectionEnd = selection_end - other_deleted_blank_size - Math.min(end_pos[1], start_deleted_blank_size);
} else {
    this.selectionEnd = selection_end - start_deleted_blank_size - other_deleted_blank_size - Math.min(end_pos[1], end_deleted_blank_size);
}
```

原因はいたって明快で、常に2文字分のスペースを削除できるわけではないので、`this.selectionEnd`の新しい値を計算するために、何文字削除したかをきちんと覚えていないといけないのです。
また、選択範囲の端が、削除されるスペースの内側にがあるときと、そうでないときでも事情がことなるのでそこのケアも必要でした。

```
␣␣(ここがstart) => -2するべき
␣(ここがstart)␣ => 同じように-2すると前の行にいってしまう！-1するべき
```

3の説明が雑になってしまいましたが、これで説明は終わりとします。

## これからやること、やりたいこと
- 雑でもいいからアイコン作る
- インデントサイズ自由に変えられるようにしたい
- 言語ごとにインデントサイズの設定を変えられるようにしたい
(\`\`\`rbだったら2, \`\`\`pyだったら4みたいに。)