Vue.jsでFacebookのハッシュタグハイライトに似た機能をなんとか作ってみる

## 背景
コンテンツ投稿型のアプリケーション開発の過程で、テキスト入力時や他の投稿を表示するときなど、FacebookやInstagramの投稿のように文章中にあるハッシュタグをハイライトさせる機能が必要な案件がありました。さらに、入力中のハッシュタグからインクリメントサーチでハッシュタグの予測語をリスト表示させ、予測語と入力中のハッシュタグを置換させるといった要望もあります。リッチテキストエディタを作る訳ではありませんが、その実装に向けてリサーチを行い、その備忘録を記します。

完成品はこんな感じです。

| Desktop | Mobile | 
| --- | --- | 
| ![desktop.gif](https://qiita-image-store.s3.amazonaws.com/0/117549/305924ad-914a-2351-72bf-d24a4d170fd0.gif)| ![mobile.gif](https://qiita-image-store.s3.amazonaws.com/0/117549/b3852b5e-a196-e7c8-40de-72367468d5a2.gif) |


## Facebookのハッシュタグハイライト
実際のFacebookでは投稿時にどのようにハイライトさせているか調べてみると、`span`タグに`contentstate`という属性が付いていて、どうやらその中身がハッシュタグに装飾を行なっている模様でした。

<img width="496" alt="スクリーンショット 2019-03-24 1.11.35.png" src="https://qiita-image-store.s3.amazonaws.com/0/117549/4dc127c1-01fa-9095-84d0-507ee8fe0122.png">

<details><summary>Facebook投稿時のハッシュタグ部分のhtml</summary><div>


```html
<span 
  contentstate="c { 
        &quot;entityMap": [object Object],
        "blockMap": OrderedMap { 
            "c9rd9": c { 
                "key": "c9rd9", 
                "type": "unstyled", 
                "text": "#aa", 
                "characterList": 
                List [ b { 
                        "style": OrderedSet {}, 
                        "entity": null }, 
                        b { 
                            "style": OrderedSet {}, 
                            "entity": null }, 
                            b { 
                                "style": OrderedSet {}, 
                                "entity": null } 
                    ], 
                "depth": 0, 
                "data": Map {} 
            } 
        }, 
        "selectionBefore": b { 
                                "anchorKey": "c9rd9", 
                                "anchorOffset": 0, 
                                "focusKey": "c9rd9", 
                                "focusOffset": 0, 
                                "isBackward": false, 
                                "hasFocus": true 
                            },
        "selectionAfter": b { 
                                "anchorKey": "c9rd9",
                                "anchorOffset": 3, 
                                "focusKey": "c9rd9", 
                                "focusOffset": 3, 
                                "isBackward": false, 
                                "hasFocus": true 
                            }}" 
  decoratedtext="#aa" 
  start="0" 
  end="3" 
  blockkey="c9rd9" 
  offsetkey="c9rd9-0-0" 
  data-offset-key="c9rd9-0-0" 
  class="_5zk7" 
  spellcheck="false">
  <span data-offset-key="c9rd9-0-0">
    <span data-text="true">#aa</span>
  </span>
</span>
```
</div></details>

中身は全く分からなかったですが、spanタグにある**`contentstate`**という見慣れない属性があり、これがヒントだと考えて調べてみると`Draft.js`というライブラリの機能のようでした。残念なことに、このDraft.jsはReact向けのライブラリのようで、今回はVueを使ったプロジェクトなので導入できません。そこで、似たような機能を搭載したVueコンポーネントを作ることにしました。

## contenteditable
[こちら](https://qiita.com/YudaiTsukamoto/items/264f333e90a1edb818a3)の記事に、Draft.jsのハッシュタグハイライトの構造がまとめられていました。作りとしては入力されたテキストに対して、ハッシュタグを抽出する正規表現を用意し、検出したハッシュタグをcallbackで返し、そのハッシュタグを装飾用の別のReactのコンポーネントで置き換える？といった流れだと思います。ここで重要なのが**`contenteditable`**というブラウザに実装されているhtmlの属性です。最初は`textarea`を使った実装にしていましたが、文字数が増えていってコンテナサイズに収まりきらなくなった時に、textareaをリサイズするための処理が複雑になったので断念しました。この`contenteditable`属性はDOMを直接編集可能にできるため、コンテナの高さを100%に設定しておけば、自動でリサイズしてくれます。ただし、v-modelが使えないため、watchなどでテキストのインプットをリアルタイムでトラッキングする方法が思いつきませんでした。私はDOMの変更を監視する[`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver)を使ってテキストの入力を監視させました。

```js
// targetはcontenteditable属性を持つ
const target = document.getElementById('input-true-text');
const observer = new MutationObserver(this.onObserveElement);    
const config = { 
                childList: true, 
                characterData: true,
                characterDataOldValue: true,
                subtree: true
              };

observer.observe(target, config);
```

## innerHTMLで置換
contenteditableのコンテナの下レイヤーにもう一つコンテナを置き、contenteditableのコンテナで入力された内容をinnerHTMLで全て置き換えます。その際、ハッシュタグの内容だけ<i>タグで囲むように変更し、ハッシュタグを装飾できるようにしました。

![structure.png](https://qiita-image-store.s3.amazonaws.com/0/117549/015671c2-6549-b31f-2432-b71d0f2df8bf.png)

ここで注意しなければいけなかった点は、innerHTMLで置換される文字列に`<`や`&`などの文字があるとhtmlとして認識されてしまうことです。そのため、一度全ての文字列からエスケープ文字だけ最初に置換してから、ハッシュタグの検出 => <i>タグ文字とともに置換 を行います。

また、なぜかSafariブラウザとその他のブラウザでは複数行の改行をした時の改行コードの数が違っていたため、Safariブラウザ以外では改行文字を1つ削除させています。

```js:methods

    onObserveElement(mutations) {
      mutations.forEach((mutation) => {
        const type = mutation.type

        switch(type) {
　　　　　　// 文字入力に変化があればここ
          case 'characterData':
            this.replaceContent()
            break;
　　　　　　
　　　　　　// 行に変化があればここ
          case 'childList':
            this.replaceContent()
            break;
          
          default:
            break;        
        }
      })
    },
    replaceContent() {
      const target = document.getElementById('input-true-text');

      // NOTE: エスケープ文字を処理する
      const content = this.escapeHtml(target.innerText)
      const contentHTML = target.textContent

      // NOTE: 改行コードを削除(Safariブラウザ以外)
      const spaceExp = /^\n\n/gm
      const content2 = content.replace(spaceExp, function(match) {
        return '\n'
      })

      // NOTE: 新しいテキストを作成
      const srcContent = this.isSafariBrowser ? content : content2
      const self = this

      // ハッシュタグ文字を置換する
      const replaceContent = srcContent.replace(this.regExp, function(match) {
        const idStr = ' id=' + self.getUniqueStr()
        const result = '<i ' + self.hashtagStyle + idStr + '>' + match + '</i>'
        return result
      })
      
      // NOTE: 表示レイヤーに置換文字を適用
      const insertNode = document.getElementById('input-overlay')
      insertNode.innerHTML = replaceContent
    },
```
## ハッシュタグの選択に対応
プレビュー時にハッシュタグを選択してハッシュタグ関連のコンテンツを表示させる、といった要望もありました。編集時にハッシュタグが選択できるようにさせると、ハッシュタグの選択なのか編集のための選択か判別できないため、編集モードとプレビューモードを分けました。プレビューモードでは単純に表示レイヤーをcontenteditableレイヤーの上に置き、DOMの編集をできないようにさせるだけです。その上で、表示レイヤーの`<i>タグ`の変更を監視します。

```js
  mounted() {    
    const overlayElm = document.getElementById('input-overlay')
    overlayElm.addEventListener("click", this.onSelectHashtag, false);
  },
  methods: {
   onSelectHashtag(e) {
      const target = e.target
      const tagName = target.tagName
      
      if (tagName === 'I') {
        const content = target.textContent
        this.$emit('onSelectHashtag', target)
      }
    },
  }
```

## その他の機能
編集中のハッシュタグの置換も実装しました。入力中のハッシュタグからインクリメントサーチをして、予測語のハッシュタグを置換させるためです。

![hashtag.gif](https://qiita-image-store.s3.amazonaws.com/0/117549/fa4138f9-881c-e196-2dcd-9f27215d65d8.gif)

## コンポーネントをライブラリ化
Vueのコンポーネントを`vue-hashtag-textarea`としてライブラリ化しました。以下、変更できる装飾オプションです。

| Options | Type | Description | Default |
|:--|:--|:--|:--|
| textColor | String | ordinary text color| black |
| font | String | wave height | 14px "Noto Sans Japanese", sans-serif |
| hashtagBackgroundColor | String | background color under hashtag| transparent |
| hashtagColor | String | hashtag color | #ff0000 |
| placeholder |  String | placeholder on empty | Sentence for placeholder #place #holder |
| isEditMode |  Boolean | **true:** enable to edit but cannot select hashtag<br>**false:** enable to select hashtag but cannot edit | true |

[npm](https://www.npmjs.com/package/vue-hashtag-textarea)と[GitHub](https://github.com/mitsuyacider/vue-hashtag-textarea)にあります。
なお、このライブラリはカーソルの管理は行なっておりません。そのため、編集中のハッシュタグを置換した後のカーソル位置は文末に置かれるので、少し使い勝手が悪いかもしれません。

## Conclusion
今回の実装に当たって、[ScrapBox](https://scrapbox.io/mitsuyacider/)というサービスのハッシュタグ機能も参考にしました。入力された文字に対して1文字ずつ`spanタグ`を追加するといった実装になっています。ScrapBoxでもcontenteditableを使っていそうな感じで、ここを起点としてcontenteditableの使い方などを調べていっています。contenteditableはなかなかクセがある属性のようで、今回作成した`vue-hashtag-texarea`もまだ不十分な点が潜んでいると思います。その点をこれから発見して改善していければと思います。