TAB / Shift + TAB でインデントを上げ / 下げする実装

# はじめに
各種エディターやQiitaなどでもサポートされている、`TAB` もしくは `Shift` + `TAB`によるインデントを上げ下げする実装をしてみました。

[[JS] Tabキーによる移動を禁止してTabを入力する。](https://qiita.com/nisda/items/da1cc0cfdaebe789d047)
※ベース部分の実装は上記の記事を参考にさせていただきました。ありがとうございました🙇‍♂️
※また、今回はvueで実装してます。

# サンプル

- 今回作成したコンポーネント
```vue:CommonTextArea.vue
<template>
  <textarea
    :id="uid"
    :value="value"
    :placeholder="label"
    @input="$emit('input', $event.target.value)"
  />
</template>

<script lang="ts">
import { Component, Prop, Vue } from "nuxt-property-decorator"

@Component
export default class CommonTextArea extends Vue {
  /* ===============================
   * props
   * =============================== */
  @Prop({ type: String, default: "" })
  value!: string

  @Prop({ type: String, default: "" })
  label!: string

  /* ===============================
   * lifecycle
   * =============================== */
  mounted() {
    // keyupだと移動後に処理が発火してしまうので注意。
    document.addEventListener("keydown", this.detectTabKey)
    this.uid = this.generateUid()
  }

  beforeDestroy() {
    document.removeEventListener("keydown", this.detectTabKey)
  }

  /* ===============================
   * methods
   * =============================== */
  /** tabキーを押下時の処理を追加 */
  detectTabKey(event: KeyboardEvent) {
    // TODO `value`などが使えないため`any`（適切な型ご存知な型いましたらぜひ教えてください・・！）
    const currentElement = document.activeElement as any
    // DOM取得できないか、異なるidのテキストエリアの場合か、textarea以外の場合は処理しない。
    if (!currentElement || currentElement.id !== this.uid || currentElement.tagName !== "TEXTAREA") return
    // shift + tab の場合
    if (event.shiftKey && event.key === "Tab") {
      const TAB = "  " // 「"\t"」にするかはお好みで。
      // 移動処理を無効
      event.preventDefault()
      const value = currentElement.value
      // sPosから現在の行数を取得する
      const sPos = currentElement.selectionStart
      const sValue = value.slice(0, sPos)
      const sValueList = sValue.split("\n")
      const currentRow = sValueList.length
      const currentIdx = currentRow - 1
      // 現在の行の値を取得
      const valueList = value.split("\n")
      const currentRowValue = valueList[currentIdx]
      // TABが含まれていなければ処理終了
      if (!currentRowValue.startsWith(TAB)) return
      // TABが含まれていたら除去して値を更新
      valueList[currentIdx] = currentRowValue.replace(TAB, "")
      const result = valueList.join("\n")
      this.$emit("input", result) // vueのdataにも反映
      currentElement.value = result
      // position移動のための処理
      // 対象行でカーソル位置までにTABが含まれているかで処理が変わる
      const sCurrentRowValue = sValueList[currentIdx]
      const isIncludeTab = sCurrentRowValue.startsWith(TAB)
      const cPos = sPos - (isIncludeTab ? TAB.length : 0)
      currentElement.setSelectionRange(cPos, cPos)
      return
    }
    // tab の場合
    if (event.key === "Tab") {
      event.preventDefault()
      const TAB = "  "
      const value = currentElement.value
      const sPos = currentElement.selectionStart
      const ePos = currentElement.selectionEnd
      const result = value.slice(0, sPos) + TAB + value.slice(ePos)
      const cPos = sPos + TAB.length
      this.$emit("input", result) // vueのdataにも反映
      currentElement.value = result
      currentElement.setSelectionRange(cPos, cPos)
    }
  }

  generateUid() {
    // 他のコンポーネントと区別がつくようにuidをつくる
    return Math.random().toString(32).substring(2)
  }
}

```

- 親コンポーネント

```vue:Parent.vue
<template>
  <CommonTextArea v-model="hogeValue" label="内容" />
</template>
```

呼び出し部分のみ抜粋して書いておりますが、親側からは`v-model`を使って呼び出せるようにしてあります😊

# おわりに

どなたかの参考になれば幸いです！（もっとよい書き方があればぜひアドバイスお願いします！！）