7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-03-24

背景

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

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

Desktop Mobile
desktop.gif mobile.gif

Facebookのハッシュタグハイライト

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

スクリーンショット 2019-03-24 1.11.35.png
Facebook投稿時のハッシュタグ部分の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>

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

contenteditable

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

// 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で全て置き換えます。その際、ハッシュタグの内容だけタグで囲むように変更し、ハッシュタグを装飾できるようにしました。

structure.png

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

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

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>タグの変更を監視します。

  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

コンポーネントをライブラリ化

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
false: enable to select hashtag but cannot edit
true

npmGitHubにあります。
なお、このライブラリはカーソルの管理は行なっておりません。そのため、編集中のハッシュタグを置換した後のカーソル位置は文末に置かれるので、少し使い勝手が悪いかもしれません。

Conclusion

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

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?