textareaの文字を色分けする（しているように見せる）方法を考えてみた

codepenやGASのエディタでは、入力された文字は次々と色分けされていきます。本記事ではそんな機能を、つまり入力された文字をそれぞれ色分けする機能を、JavaScriptのライブラリを駆使して「簡単に」再現してみようと思います。…再現と書きましたが、原理は根本的に異なるものであるということはあしからず。
> ![sfdfg.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532858/d44b8088-c30b-ec42-efae-db2931f7b50c.png)
> サンプルのスクリーンショット
> CSSを入力したら、こんな具合に色分けする。

###必要なもの
- [highlight.js](https://highlightjs.org/) (JavaScriptライブラリ)
- JavaScript/CSSについての最低限の知識

#0. はじめに知っておくべきこと
ご存知の方もいらっしゃるかもしれませんが、textarea内の文字をそれぞれ別の色に変えることは**できません**（textareaの文字色を赤にしてSVGフィルターをかけたspanを各文字の位置に重ねるという泥臭い方法はありますが、機能性も低くハイコスト・ローリターンです）。

ではどうするのか……以下のようにすればよいのです。

- textareaの上に「textareaではないがtextareaと同じスタイル」の要素Aを被せる
- textareaの文字色を透明にする
- textarea内の文章を要素Aに連動させる
- 要素A内に収められた文章をハイライト用ライブラリ（highlight.js）でじっくり料理する


#1. 要素Aを作る
ここでいう要素Aは、highlight.jsを適用できるような要素でなければなりません。つまり

```html:sample.html
<html>
  <head>
    <link rel="stylesheet" href="style.css">

    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/dracula.min.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/atom-one-dark.min.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></script>
  </head>

  <body>

    <!--以降のHTMLの例はここから-->
    <div class="twrap">
      <textarea></textarea>

      <pre><!--要素A-->
        <code class="css"></code>
      </pre>
    </div>
    <!--ここまでのみ表示-->

    <script src="script.js"></script>
  </body>
</html>
```
こんな具合です。

#2. 要素Aとtextareaの見た目を合わせる
まず我々には二つの道があります。

- textareaをスクロールさせる（textareaの高さを固定する）
- textareaをスクロールさせない（textareaの高さを可変にする）

本記事では「textareaをスクロールさせない」を選びます。
> **Tips あるいは失敗談**
> textareaをスクロールさせる方が見栄えは良いかもしれません。textareaのscroll量を要素Aに連動させるのは簡単ですし、以降の説明にある作業の多くが不要になりますからね。ですが、そのページがiPhoneからの閲覧を想定しているものならば、やめた方が良いです。iPhoneの慣性スクロールにおいては、上端あるいは下端を越えてその要素を引っ張ることができますが、そのうち上端を越える場合、JavaScript側ではscrollイベントが更新されず、そのscroll量はマイナスになってくれません。その間、textareaと要素Aの位置関係に齟齬が生まれてしまいます。

まずはtextareaと要素Aのスタイルを合致させましょう。

```css:style.css
.twrap textarea {
  -webkit-appearance: none;
  border: 1px solid #aaa;
  border-radius: 0;
  box-sizing: border-box;
  font-size: .8rem;
  height: 100%;
  margin: 0;
  padding: 0 .25em;
  resize: none;
}
.twrap pre {
  margin: 0;
  padding: 0;
  pointer-events: none;/*要素Aを触れなくする*/
}
.twrap pre code {
  border: 1px solid transparent;
  box-sizing: border-box;
  font-size: .8rem;
  height: 100%;
  margin: 0;
  padding: 0 .25em;
  white-space: pre-wrap;
  word-break: break-all;
}
```
#3. textareaと要素Aを重ねる

一度textareaと要素Aの位置関係を確認してみましょう。

```html:sample.html
    <div class="twrap">
      <textarea></textarea>

      <pre><!--要素A-->
        <code class="css"></code>
      </pre>
    </div>
```

この場合、`.twrap`に`position: relative;`を、要素Aに`position: absolute;`を設定しましょう。

```css:style.css
.twrap {
  position: relative;/*new*/
}
.twrap textarea {
  -webkit-appearance: none;
  border: 1px solid #aaa;
  border-radius: 0;
  box-sizing: border-box;
  font-size: .8rem;
  margin: 0;
  padding: 0 .25em;
  resize: none;
}
.twrap pre {
  left: 0;/*new*/
  margin: 0;
  padding: 0;
  pointer-events: none;
  position: absolute;/*new*/
  top: 0;/*new*/
}
.twrap pre code {
  border: 1px solid transparent;
  box-sizing: border-box;
  font-size: .8rem;
  margin: 0;
  padding: 0 .25em;
  white-space: pre-wrap;
  word-break: break-all;
}
```

#4. textarea内の文章を要素Aに連動させる＆要素Aをハイライト

```javascript:script.js
var textarea = document.querySelector(".twrap textarea");
var dummy = document.querySelector(".twrap pre code");

textarea.oninput = function() {
  dummy.innerText = textarea.value + "\u200b";//textareaの値の最後が改行コードだった場合に対応するためのゼロ幅スペース
  hljs.highlightBlock(dummy);
}
```

#5. textareaと要素Aをリサイズする
これをしないと、textareaと要素Aそれぞれの文字の位置がズレます。

Googleで検索すると、textareaのリサイズについての記事が幾つか散見されます。offsetHeightとscrollHeightの違いなどをもとにwhileループで最適の大きさを求めるものなどです。スマートですね。ですが本記事の場合、もっとコストの低い良いものがあるので、それらは使いません。

ではどうするのか……要素Aのありのままの大きさを測り、それを`.twrap`に`height`として反映すれば良いのです。そしてtextareaと要素Aに`height: 100%;`をかけておけば、それらの大きさは`.twrap`の大きさに依存するようになります。

###下準備:スタイルを合わせる

```css:style.css
.twrap {
  position: relative;
}
.twrap textarea {
  -webkit-appearance: none;
  border: 1px solid #aaa;
  border-radius: 0;
  box-sizing: border-box;
  font-size: .8rem;
  height: 100%;/*new*/
  margin: 0;
  padding: 0 .25em;
  resize: none;
}
.twrap pre {
  height: 100%;/*new*/
  left: 0;
  margin: 0;
  padding: 0;
  pointer-events: none;
  position: absolute;
  top: 0;
}
.twrap pre code {
  border: 1px solid transparent;
  box-sizing: border-box;
  font-size: .8rem;
  height: 100%;/*new*/
  margin: 0;
  padding: 0 .25em;
  white-space: pre-wrap;
  word-break: break-all;
}
.twrap pre code.resizing {
  height: unset;/*new*/
}
```
###リサイズ用のコードを入れる

```javascript:script.js
var twrap = document.querySelector(".twrap");
var textarea = twrap.querySelector("textarea");
var dummy = twrap.querySelector("pre code");

resizeTA();

textarea.oninput = function() {
  dummy.innerText = textarea.value + "\u200b";//textareaの値の最後が改行コードだった場合に対応するためのゼロ幅スペース
  hljs.highlightBlock(dummy);
  resizeTA();
}

function resizeTA() {
  dummy.classList.add("resizing");//ありのままの大きさに戻す
  twrap.style.height = (dummy.scrollHeight + 20) + "px";//念の為に20pxほどマージンを取っている
  dummy.classList.remove("resizing");
}
```

#6. textareaの文字色と要素Aの背景色を透明にし、highlight.jsでfont-weight: bold;にされたspanをnormalにする

> **Tips あるいは失敗談**
> 本記事ではtextareaと要素Aのフォントをmonospace系にしていますが、これは等幅フォントの利便性を鑑みた結果です。monospace系は文字サイズさえ同じならば、如何な文字も、あるいは太字やイタリック体であろうと、同じ幅になるためです。いえ、そうなる予定でした。しかしiPhoneの場合、太字にすると幅が少しだけ大きくなります。PCやAndroidだと同じ幅なのに…。そんなわけで、太字も無理やり通常の太さに戻してしまっているので、必ずしもmonospace系にこだわる必要はないでしょう。

```css:style.css
.twrap {
  position: relative;
}
.twrap textarea {
  -webkit-appearance: none;
  border: 1px solid #aaa;
  border-radius: 0;
  box-sizing: border-box;
  caret-color: #000;/*new*/
  color: transparent;/*new*/
  font-family: monospace;/*new*/
  font-size: .8rem;
  height: 100%;
  margin: 0;
  padding: 0 .25em;
  resize: none;
}
.twrap pre {
  height: 100%;
  left: 0;
  margin: 0;
  padding: 0;
  pointer-events: none;
  position: absolute;
  top: 0;
}
.twrap pre code {
  background: transparent;/*new*/
  border: 1px solid transparent;
  box-sizing: border-box;
  font-family: monospace;/*new*/
  font-size: .8rem;
  height: 100%;
  margin: 0;
  padding: 0 .25em;
  white-space: pre-wrap;
  word-break: break-all;
}
.twrap pre code.resizing {
  height: unset;
}
.twrap pre code span {
  font-weight: normal;
}
```
あとは各要素の大きさに影響を与えない範囲でデザインすれば完成です。

#おわりに
iOSを開発されている方々に一言物申したいですね。iOSには面白い機能がたくさんあり、彼らの独創性・発想力、目を見張るものがあると思います。利便性を謳いたくなるのも分かります。ですがその前に、計画性と協調路線の存在にしっかりと目を向けてもらいたいですね。ここまでで挙げた通り、iOSのせいで余計な回り道をさせられています。この例に限った話ではないです。独自路線を突っ走り過ぎると嫌われますよ。


#サンプル
> http://topia.wdfiles.com/local--code/css-parser-1/1