Vue.jsでSlackみたいなメンションと入力中の名前色付けを作った完成形コードとハマった話

Repsona LLCの[@GussieTech](https://twitter.com/GussieTech)です。[「理想のプロジェクト管理ツール」Repsona](https://repsona.com/ja)を開発しています。

## 作ったもの

これを作るのに大変苦労しました。textareaの特定文字に色はつけられません。ではどうするか？そんなお話です。

![スクリーンショット 2021-04-16 14.33.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/98c2990e-69a8-4433-de84-e435e44867f6.png)

## 忙しい人へ

完成形のコード抜粋をCodeSandboxにつくりましたのでご利用ください。でも・・僕の苦労話も是非読んでいってください・・

https://codesandbox.io/s/vue-mention-bootstrap-like-slack-lswuu

## 環境

Vue v2.x + Bootstrap v4.x + vue-mention

## ハマった話と解決法

RepsonaではSlackみたいにメンションでお知らせできるようになっています。受信トレイの新規開発に合わせて、メンションの体感もカイゼンしようということで立ち向かいました。色付きメンションの記事は案外ないのでお役に立てれば嬉しいです。

### もくじ

- メンションポップアップ
    - Tributeが期待した位置に表示されてくれない
    - vue-mentionが表示されない
    - vue-mentionの表示位置がおかしい
    - 全角＠で期待通りに動いてくれない
- 入力中の名前色付け
    - textarea内の文字に色をつける方法がない
    - スクロールバーの有無で色付け位置がズレる

### メンションポップアップ

#### Tributeが期待した位置に表示されてくれない

リリース当初より[Tribute](https://github.com/zurb/tribute)でメンションの動作を実装していました。通常の文字数や表示位置では問題なく動作していたのですが、画面全体のスクロール位置や入力文字数によって期待した位置に表示されない問題がありました。かなり試行錯誤をしたのですがうまくいかなかったため、[vue-mention](https://github.com/Akryum/vue-mention)に乗り換えることにしました。

#### vue-mentionが表示されない

乗り換えたところ、なぜか表示されませんでした。これがかなりハマりました。vue-mentionはv-tooltipに依存しているのですが、Bootstrapの`.tooltip`クラスとぶつかって、なんと`opacity: 0;`が採用されてしまっていました。

![スクリーンショット 2021-04-16 10.42.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/49df0a33-c7d9-1c2d-06ad-ff0fa6253ce2.png)
<img width="400" alt="スクリーンショット 2021-04-16 10.43.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/7f688b5e-3780-a0e4-6649-7033d32e22e4.png">

v-tooltip内の`.tooltip`に関しては、`opacity: 1;`が適用されるようにCSSを設定して回避しました。

```scss
[id^="popover_"].tooltip {
  opacity: 1;
}
```

#### vue-mentionの表示位置がおかしい

![スクリーンショット 2021-04-16 10.55.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/342d6a0a-5da9-7d6a-e465-9835a22bc454.png)

いや違う、左側ではなく右側にでて欲しいのです。デモでは右に出ているのです。

![スクリーンショット 2021-04-16 10.57.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/a53f09fb-0d0b-326f-b9b6-35cea6424dab.png)

細かい原因は調べませんでしたが、きっとまたBootstrapのCSSと競合しているのだろうと思いました。そこでまるっとv-tooltipのCSSが優先して適用されれば良いのではないかと考えました。

CSSは、**対象を詳しく指定しているセレクタが優先される**ので、idと合わせて指定することでオーバーライドしてやります（`[id^="popover_"].tooltip`）。[v-tooltipのデフォルトのCSS](https://github.com/Akryum/v-tooltip#sass--less)で上書きすることで、うまく解決しました。

```scss
[id^="popover_"].tooltip {
  display: block !important;
  z-index: 10000;

  .tooltip-inner {
    background: black;
    color: white;
    border-radius: 16px;
    padding: 5px 10px 4px;
  }

  .tooltip-arrow {
    width: 0;
    height: 0;
    border-style: solid;
    position: absolute;
    margin: 5px;
    border-color: black;
    z-index: 1;
  }

  &[x-placement^="top"] {
    margin-bottom: 5px;

    .tooltip-arrow {
      border-width: 5px 5px 0 5px;
      border-left-color: transparent !important;
      border-right-color: transparent !important;
      border-bottom-color: transparent !important;
      bottom: -5px;
      left: calc(50% - 5px);
      margin-top: 0;
      margin-bottom: 0;
    }
  }

  &[x-placement^="bottom"] {
    margin-top: 5px;

    .tooltip-arrow {
      border-width: 0 5px 5px 5px;
      border-left-color: transparent !important;
      border-right-color: transparent !important;
      border-top-color: transparent !important;
      top: -5px;
      left: calc(50% - 5px);
      margin-top: 0;
      margin-bottom: 0;
    }
  }

  &[x-placement^="right"] {
    margin-left: 5px;

    .tooltip-arrow {
      border-width: 5px 5px 5px 0;
      border-left-color: transparent !important;
      border-top-color: transparent !important;
      border-bottom-color: transparent !important;
      left: -5px;
      top: calc(50% - 5px);
      margin-left: 0;
      margin-right: 0;
    }
  }

  &[x-placement^="left"] {
    margin-right: 5px;

    .tooltip-arrow {
      border-width: 5px 0 5px 5px;
      border-top-color: transparent !important;
      border-right-color: transparent !important;
      border-bottom-color: transparent !important;
      right: -5px;
      top: calc(50% - 5px);
      margin-left: 0;
      margin-right: 0;
    }
  }

  &.popover {
    $color: #f9f9f9;

    .popover-inner {
      background: $color;
      color: black;
      padding: 24px;
      border-radius: 5px;
      box-shadow: 0 5px 30px rgba(black, .1);
    }

    .popover-arrow {
      border-color: $color;
    }
  }

  &[aria-hidden='true'] {
    visibility: hidden;
    opacity: 0;
    transition: opacity .15s, visibility .15s;
  }

  &[aria-hidden='false'] {
    visibility: visible;
    opacity: 1;
    transition: opacity .15s;
  }
}
```

きました！（幅やカラー指定の調整は省略しています）

![スクリーンショット 2021-04-16 11.02.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/d56d70e1-6daa-cc08-bbd0-5cd1f07687c4.png)

#### 全角＠で期待通りに動いてくれない

全角＠でポップアップが出て、確定するとなんだか＠がいっぱい出てきてしまいます。

![yyy.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/7d39a7e5-3bb2-a709-3e71-6fb04daf332e.gif)

ドキュメントに記載はないのですが、どうやら[隠しオプション](https://github.com/Akryum/vue-mention/blob/master/packages/vue-mention/src/Mentionable.vue)みたいなものがあって、omitKeyというのを見つけました。

これをセットすることで、起動キーを排除した上で、valueをセットできるようになりました。valueには@も含めることで、期待通り動作します。でも、まだもうひとつ＠が残ってしまいます。

![xxx.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/05807829-cd45-ba60-2b5f-6b1c61e1f536.gif)

IMEの変換確定とメンションの決定が重なって、決定後に＠が残ってしまっています。IME確定後のみ決定を発動させる必要がありそうです。

vue-mentionのコードをみてみるとonKeyDown()が担っているようです。この部分ですね。

```js
if ((e.key === 'Enter' || e.key === 'Tab' || e.keyCode === 13 || e.keyCode === 9) &&
```

非常にdirtyですが`e.key === 'Enter' || e.key === 'Tab'`を無効にするオーバーライドをすることで回避しました。しかし、keyCodeはdeprecatedなので、いずれ期待通り動かなくなるかもしれません。他サービスで全角＠が気持ちよく動作しないものがあるのは、きっとこの辺りの事情があるのでしょう。英語圏の人たちには関係ないでしょうし・・。ところで、正しくはどう実装するべきなのだろう。

### 入力中の名前色付け

#### textarea内の文字に色をつける方法がない

さまざまなサービスで簡単そうに実現しているのですが、甘くみていました。`textarea css font-color`などとググってみても、どうやらtextarea内の特定の文字に色をつける方法はないようです。

そうすると`contenteditable`が頭をよぎりますが、プレーンテキストとして取り扱うのはかなりしんどさがあります（以前とんでもなくハマったのですがここでは省略します）。

そこで、textareaに、入力中の文字とまったく同じ文字を表示するdivを重ねる方法を採用しました。

![スクリーンショット 2021-04-16 11.22.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/bc6915e5-3c87-160d-3665-621ff4a19d90.png)

ピンクで表示したdiv内にtextareaで記述した内容をそのまま透明文字で表示して、必要に応じて`<span>`等でカラーをつける作戦です。仮実装してすぐ期待通りに動いたので、簡単そうに見えました。しかし・・・

#### スクロールバーの有無で色付け位置がズレる

![スクリーンショット 2021-04-16 11.28.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/9980ea78-3c10-9b51-9f51-862dd0ee496b.png)
![スクリーンショット 2021-04-16 11.28.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/f511e433-5f6d-674a-1bf1-7a51b4665bb1.png)

ああ・・なるほど。スクロールバーさんを忘れていました。サイズ変更などでも表示されますね。スクロールバー表示有無を検出してスクロールバー幅分をpaddingしてやる必要がありそうです。

```ts
const barWidth = this.$refs.comment.$el.getBoundingClientRect().width - this.$refs.comment.$el.clientWidth
```

（実際はtextareaに適用されたボーダーなどのスタイルも加味して計算しています）

うまくいきました。

![スクリーンショット 2021-04-16 11.33.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/4cb0847a-df81-e726-bb8d-9b813da63ef7.png)

## 完成形

https://codesandbox.io/s/vue-mention-bootstrap-like-slack-lswuu

```vuejs
<template>
  <div id="app">
    <div class="p-3">
      <div class="position-relative">
        <div
          ref="textareaCover"
          class="textarea-cover"
          v-html="commentMention"
        ></div>
        <mentionable :keys="['@', '＠']" :items="users" insert-space omit-key>
          <textarea
            ref="comment"
            class="form-control comment"
            rows="4"
            v-model="comment"
            @keyup="commentScroll"
            @scroll="commentScroll"
          />

          <template #item="{ item }">
            <div class="user">
              <span class="font-weight-bold">
                {{ item.value }}
              </span>
              <span class="ml-2">
                {{ item.firstName }}
              </span>
            </div>
          </template>
        </mentionable>
      </div>
    </div>
  </div>
</template>

<script>
import { Mentionable } from "vue-mention";

Mentionable.methods.onKeyDown = function (e) {
  if (this.key) {
    if (e.key === "ArrowDown" || e.keyCode === 40) {
      this.selectedIndex++;
      if (this.selectedIndex >= this.displayedItems.length) {
        this.selectedIndex = 0;
      }
      this.cancelEvent(e);
    }
    if (e.key === "ArrowUp" || e.keyCode === 38) {
      this.selectedIndex--;
      if (this.selectedIndex < 0) {
        this.selectedIndex = this.displayedItems.length - 1;
      }
      this.cancelEvent(e);
    }
    if (
      (e.keyCode === 13 || e.keyCode === 9) &&
      this.displayedItems.length > 0
    ) {
      this.applyMention(this.selectedIndex);
      this.cancelEvent(e);
    }
    if (e.key === "Escape" || e.keyCode === 27) {
      this.closeMenu();
      this.cancelEvent(e);
    }
  }
};

export default {
  name: "App",
  components: {
    Mentionable,
  },
  data() {
    return {
      comment: "",
      users: [
        {
          value: "@akryum",
          firstName: "Guillaume",
        },
        {
          value: "@posva",
          firstName: "Eduardo",
        },
        {
          value: "@atinux",
          firstName: "Sébastien",
        },
      ],
    };
  },
  computed: {
    commentMention() {
      if (typeof this.comment?.replaceAll !== "function") {
        return this.comment;
      }
      const replaced =
        this.comment
          ?.replaceAll("&", "&amp;")
          ?.replaceAll(">", "&gt;")
          ?.replaceAll("<", "&lt;") + "\n\n";
      const search = new RegExp(
        this.users
          .slice()
          .sort((a, b) => b.value?.length - a.value?.length)
          .map((user) => user.value)
          .join("|"),
        "g"
      );
      return replaced.replace(search, (match, offset) => {
        return `<span class="mention-str">${match}</span>`;
      });
    },
  },
  methods: {
    mounted() {
      setTimeout(() => {
        this.resize();
        window.addEventListener("resize", this.resize);
        this.$once("hook:beforeDestroy", () => {
          window.removeEventListener("resize", this.resize);
        });
      });
    },
    resize() {
      const barWidth =
        this.$refs.comment.getBoundingClientRect().width -
        this.$refs.comment.clientWidth -
        2; // border
      this.$refs.textareaCover.style.paddingRight = `calc(12px + ${barWidth}px)`;
    },
    commentScroll() {
      this.$refs.textareaCover.scrollTop = this.$refs.comment.scrollTop;
      this.resize();
    },
  },
};
</script>
```

```scss
[id^="popover_"].tooltip {
  display: block !important;
  z-index: 10000;

  .tooltip-inner {
    background: black;
    color: white;
    border-radius: 16px;
    padding: 5px 10px 4px;
  }

  .tooltip-arrow {
    width: 0;
    height: 0;
    border-style: solid;
    position: absolute;
    margin: 5px;
    border-color: black;
    z-index: 1;
  }

  &[x-placement^="top"] {
    margin-bottom: 5px;

    .tooltip-arrow {
      border-width: 5px 5px 0 5px;
      border-left-color: transparent !important;
      border-right-color: transparent !important;
      border-bottom-color: transparent !important;
      bottom: -5px;
      left: calc(50% - 5px);
      margin-top: 0;
      margin-bottom: 0;
    }
  }

  &[x-placement^="bottom"] {
    margin-top: 5px;

    .tooltip-arrow {
      border-width: 0 5px 5px 5px;
      border-left-color: transparent !important;
      border-right-color: transparent !important;
      border-top-color: transparent !important;
      top: -5px;
      left: calc(50% - 5px);
      margin-top: 0;
      margin-bottom: 0;
    }
  }

  &[x-placement^="right"] {
    margin-left: 5px;

    .tooltip-arrow {
      border-width: 5px 5px 5px 0;
      border-left-color: transparent !important;
      border-top-color: transparent !important;
      border-bottom-color: transparent !important;
      left: -5px;
      top: calc(50% - 5px);
      margin-left: 0;
      margin-right: 0;
    }
  }

  &[x-placement^="left"] {
    margin-right: 5px;

    .tooltip-arrow {
      border-width: 5px 0 5px 5px;
      border-top-color: transparent !important;
      border-right-color: transparent !important;
      border-bottom-color: transparent !important;
      right: -5px;
      top: calc(50% - 5px);
      margin-left: 0;
      margin-right: 0;
    }
  }

  &.popover {
    $color: #f9f9f9;

    .popover-inner {
      background: $color;
      color: black;
      padding: 10px;
      border-radius: 5px;
      box-shadow: 0 5px 30px rgba(black, 0.1);
    }

    .popover-arrow {
      border-color: $color;
    }
  }

  &[aria-hidden="true"] {
    visibility: hidden;
    opacity: 0;
    transition: opacity 0.15s, visibility 0.15s;
  }

  &[aria-hidden="false"] {
    visibility: visible;
    opacity: 1;
    transition: opacity 0.15s;
  }
}

.mention-item {
  padding: 4px 10px;
  border-radius: 4px;
}

.mention-selected {
  background: #00ABE7;
  color: white;
}

.textarea-cover {
  z-index: 1;
  position: absolute;
  color: transparent;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  overflow-y: hidden;
  padding: 6px 12px;
  border: 1px solid transparent;
}

.comment {
  width: 100%;
}

.mention-str {
  color: #00ABE7;
  background-color: rgba(0, 171, 231, 0.05);
}
```

実際のサービス上は血の滲むようなCSSの調整をがんばっております。もしかしたらまだバグがあるかもしれません。何かお気づきの点がありましたらお知らせいただけますと幸いでございます。

## まとめ

- vue-mention + Bootstrap は css をオーバーライド（リセット）して使う
- 全角＠問題はkeyCodeで解決する（ただしdepricatedなのが悩ましい）
- textareaに色付けできないので上に文字を重ねて表現する

簡単そうで実は難しい「普通の動き」の実装にとっても苦労してハマりました。これで気持ちよくメンションして受信トレイでサラサラと通知を管理できるようになりそうです。ますます便利になった[「プロジェクト管理と情報共有のためのツール（ガントチャート無料）」Repsona](https://repsona.com/ja)、ぜひお試しください。