Editorjsにメンション機能を実装する

# 前提
制約がないのであれば、ライブラリを使用する方が効率がいい。どうしても自作する必要がある人やメンションの実装をしてみたい人に向けた共有記事です。

[vue-mention](https://github.com/Akryum/vue-mention)

# 環境
Editorjs v2.22.2
Vue 2.6.14
Vuetify 2.5.8

#コンセプト
MessangerやSlackのように「＠」の入力に対してメニューを表示し、選択したユーザーをメンションする。

#設計
1. Editorjsのreadyを使いDom生成を検知、Editorjsのsaveを使い編集を検知
2. Editorjsの一番外枠にMutationObserverをつけて、ブロックの変更を検知する。
3. 動的に追加されるDomでメンション可能なものにさらにMutationObserverをつけて監視
4. 行頭やスペース後の＠に対してメニューを表示
5. 表示メニューの選択に応じてアンカータグ（メンション）追加


#　実装物
![mention_gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209804/af0a1647-a1df-a320-9445-c074924f4ba3.gif)


```vue:ArticleEditor.vue
<template>
  <div :id="uniqueId" />
</template>

<script>
import EditorJS from "@editorjs/editorjs";
import Header from "@editorjs/header";
import LinkTool from "@editorjs/link";
import ImageTool from "@editorjs/image";
import CheckList from "@editorjs/checklist";
import List from "@editorjs/list";
import Embed from "@editorjs/embed";
import Quote from "@editorjs/quote";
import Delimiter from "@editorjs/delimiter";
import Table from "@editorjs/table";
import gb from "../../../mixins/global/gb";
export default {
  name: "ArticleEditor",
  mixins: [gb],
  props: {
    article: { type: Object, default: () => ({}) },
    readOnly: { type: Boolean, default: false }
  },
  data: () => ({
    editor: {},
    uniqueId: `_${gb.methods.generateId()}`
  }),
  mounted() {
    this.setEditor();
  },
  methods: {
    setEditor() {
      this.editor = new EditorJS({
        data: this.article,
        placeholder: "テキストを入力...",
        autofocus: true,
        readOnly: this.readOnly,
        minHeight: 0,
        onChange: function() {
          this.save();
        }.bind(this),
        holder: this.uniqueId,
        tools: {
          header: {
            class: Header,
            shortcut: "CMD+SHIFT+H",
            config: {
              placeholder: "ヘッダー",
              levels: [1, 2, 3, 4],
              defaultLevel: 3
            }
          },
          checklist: {
            class: CheckList,
            inlineToolbar: true
          },
          list: {
            class: List,
            inlineToolbar: true
          },
          embed: {
            class: Embed,
            config: {
              services: {
                youtube: true,
                twitter: true
              }
            }
          },
          quote: {
            class: Quote,
            inlineToolbar: true,
            shortcut: "CMD+SHIFT+O",
            config: {
              quotePlaceholder: "テキストを入力",
              captionPlaceholder: "キャプションを入力"
            }
          },
          delimiter: Delimiter,
          table: {
            class: Table,
            inlineToolbar: true,
            config: {
              rows: 2,
              cols: 3
            }
          }
        },
        i18n: {
          messages: {
            ui: {
              blockTunes: {
                toggler: {
                  "Click to tune": "クリックして調整",
                  "or drag to move": "ドラッグして移動"
                }
              },
              inlineToolbar: {
                converter: {
                  "Convert to": "変換"
                }
              },
              toolbar: {
                toolbox: {
                  Add: "追加"
                }
              }
            },
            toolNames: {
              Text: "テキスト",
              Heading: "タイトル",
              List: "リスト",
              Checklist: "チェックリスト",
              Quote: "引用",
              Delimiter: "直線",
              Table: "表",
              Link: "リンク",
              Bold: "太字",
              Italic: "斜体",
              Image: "画像"
            },
            blockTunes: {
              deleteTune: {
                Delete: "削除"
              },
              moveUpTune: {
                "Move up": "上に移動"
              },
              moveDownTune: {
                "Move down": "下に移動"
              }
            }
          }
        },
        onReady: () => {
          this.$emit("ready", this.uniqueId);
        }
      });
    },
    save() {
      this.editor
        .save()
        .then(data => {
          this.$emit("saved", data);
        })
        .catch(err => {
          console.log(err);
        });
    },
    clear() {
      this.editor.clear();
    },
    render(body) {
      this.editor.render(body);
    }
  }
};
</script>
```

```vue:EditorComponent.vue
<template>
  <div>
    <v-menu
      absolute
      :position-x="mentionMenu.x"
      :position-y="mentionMenu.y"
      :value="mentionMenu.show"
      rounded
    >
      <v-list dense max-height="100">
        <v-list-item
          v-for="user in mentionableUsers"
          :key="user.id"
          dense
          @click="insertMention(user)"
        >
          <v-list-item-avatar>
            <v-img v-if="user.picture" :src="user.picture" />
            <v-icon v-else>mdi-account</v-icon>
          </v-list-item-avatar>
          <v-list-item-title>{{ user.name }}</v-list-item-title>
        </v-list-item>
        <v-list-item v-if="!mentionableUsers.length">
          <v-list-item-subtitle>
            メンション可能な人がいません。
          </v-list-item-subtitle>
        </v-list-item>
      </v-list>
    </v-menu>
    <div style="max-height: 500px" class="overflow-y-auto">
      <ArticleEditor
        ref="editor"
        :article="article"
        @saved="updateArticle"
        @ready="setBlockObserver"
      />
    </div>
  </div>
</template>
import { getUsers } from "../../../../helpers/User";
import ArticleEditor from "../../../themes/editor/ArticleEditor";
import gb from "../../../../mixins/global/gb";
<script>
export default {
  name: "TimelinePostEditor",
  components: { ArticleEditor },
  mixins: [gb],
  props: {
    article: {
      type: Object,
      default: () => ({})
    },
    mentions: {
      type: Array,
      default: () => []
    }
  },
  data: () => ({
    mentionMenu: {
      x: 0,
      y: 0,
      show: false,
      selectedParagraphIndex: null
    },
    users: [],
    editorId: ""
  }),
  watch: {
    article(article) {
      const mentionUserIds = [];
      if (!article.blocks) return;
      article.blocks.forEach(block => {
        if (block.type === "paragraph") {
          const mentions = block.data.text.match(/<a(?: .+?)?>@.*?<\/a>/g);
          if (mentions) {
            mentions.map(m => {
              const id = m.match(/href="\d"/)[0];
              if (id) {
                mentionUserIds.push(Number(id.match(/\d/)[0]));
              }
            });
          }
        }
      });
      this.updateMentions(mentionUserIds);
    }
  },
  mounted() {
    getUsers().then(res => {
      this.users = res.data;
    });
  },
  computed: {
    mentionableUsers() {
      return this.users.filter(u => !this.mentions.includes(u.id));
    }
  },
  methods: {
    setBlockObserver(editorId) {
      this.editorId = editorId;
      const editorDiv = document.getElementById(editorId);
      const elm = editorDiv.getElementsByClassName("codex-editor__redactor")[0];
      const observer = new MutationObserver(
        function() {
          this.setParagraphNodeObserver();
        }.bind(this)
      );
      const config = {
        childList: true
      };
      observer.observe(elm, config);
      this.setParagraphNodeObserver();
    },
    setParagraphNodeObserver() {
      const paragraphNodeObserverConfig = {
        characterData: true,
        attributes: false,
        childList: false,
        subtree: true
      };
      const editorDiv = document.getElementById(this.editorId);
      const paragraphNodes = editorDiv.getElementsByClassName("ce-paragraph");
      Array.from(paragraphNodes).forEach((pn, pi) => {
        const paragraphNodeObserver = new MutationObserver(
          function(mutations) {
            mutations.forEach(r => {
              const selection = document.getSelection();
              const regex = RegExp("(\\s|　)@", "g");
              const currentCaretPosition = selection.focusOffset;
              const initialMention =
                r.target.data.indexOf("@") === 0 && currentCaretPosition === 1;
              const contextMentions = [...r.target.data.matchAll(regex)];
              const isMention =
                initialMention ||
                contextMentions.filter(cm => {
                  return cm.index + cm[0].length === currentCaretPosition;
                }).length;
              if (isMention) {
                const rangeRect = selection
                  .getRangeAt(0)
                  .getBoundingClientRect();
                const { x, y } = rangeRect;
                this.mentionMenu.selectedParagraphIndex = pi;
                this.mentionMenu.x = x;
                this.mentionMenu.y = y;
                this.mentionMenu.show = true;
              } else {
                this.mentionMenu.selectedParagraphIndex = null;
                this.mentionMenu.x = 0;
                this.mentionMenu.y = 0;
                this.mentionMenu.show = false;
              }
            });
          }.bind(this)
        );
        paragraphNodeObserver.observe(pn, paragraphNodeObserverConfig);
      });
    },
    insertMention(user) {
      const editorDiv = document.getElementById(this.editorId);
      const paragraphNodes = editorDiv.getElementsByClassName("ce-paragraph");
      Array.from(paragraphNodes).forEach((pn, pi) => {
        if (this.mentionMenu.selectedParagraphIndex === pi) {
          const selection = document.getSelection();
          const currentCaretPosition = selection.focusOffset;
          if (currentCaretPosition === 1) {
            let text = pn.textContent;
            text = text.slice(1, text.length);
            text =
              `<a href="${user.id}"class="text-decoration-none font-weight-bold">@${user.name}</a>` +
              text;
            pn.innerHTML = text;
            this.addMentionedUser(user.id);
          } else {
            const node = document.getSelection();
            let targetText = node.anchorNode.data ? node.anchorNode.data : "";
            const regex = RegExp("(\\s|　)@", "g");
            let contextMentions = [...targetText.matchAll(regex)];
            const targetPoint = contextMentions.find(cm => {
              return cm.index + cm[0].length === node.anchorOffset;
            });
            if (targetPoint) {
              let before = targetText.slice(0, targetPoint.index);
              let after = targetText.slice(
                targetPoint.index + targetPoint[0].length,
                targetText.length
              );
              const mention =
                before +
                `<a href="${user.id}" data-user-id="${user.id}" class="text-decoration-none font-weight-bold">@${user.name}</a>` +
                after;
              if (pn.innerHTML.match(targetText)) {
                pn.innerHTML = pn.innerHTML.replace(targetText, mention);
              } else {
                const escapedText = this.escapeHtml(targetText);
                //$nbsp
                const replacedText = escapedText.replace(
                  String.fromCharCode(160),
                  "&nbsp;"
                );
                if (pn.innerHTML.match(replacedText) !== null) {
                  pn.innerHTML = pn.innerHTML.replace(replacedText, mention);
                }
              }
            }
            this.addMentionedUser(user.id);
          }
        }
      });
    },
    addMentionedUser(userId) {
      const mentions = [...this.mentions];
      !this.mentions.find(id => id === userId) && mentions.push(userId);
      this.updateMentions(mentions);
    },
    updateMentions(mentions) {
      this.$emit("updateMentions", mentions);
    },
    updateArticle(article) {
      this.$emit("updateArticle", article);
    },
    clear() {
      this.$refs.editor.clear();
    }
  }
};
</script>
```

# Editorjsでreadyとチェンジイベントを利用する。
初期化時にonReadyイベントを設定
holderのidを動的に設定しているのは、一画面で複数のエディターを利用するケースがあるため。

```vue:ArticleEditor.vue
<script>
//省略
      this.editor = new EditorJS({
        data: this.article,
        placeholder: "テキストを入力...",
        autofocus: true,
        readOnly: this.readOnly,
        minHeight: 0,
        onChange: function() {
          this.save(); //ブロックの追加や、テキストの編集などに応じて　saveし、最新のオブジェクトを共有
        }.bind(this),
        holder: this.uniqueId,
        tools: {},
        i18n: {},
        onReady: () => {
          this.$emit("ready", this.uniqueId); //準備完了の検知とholder divのid共有
        }
      });
//省略
    save() {
      this.editor
        .save()
        .then(data => {
          this.$emit("saved", data);
        })
        .catch(err => {
          console.log(err);
        });
    },
</script>
```

# MutationObserverの活用
Editorjsがいい感じに追加してくれるブロックに対して、それぞれMutationObserverをつけ、「＠」の検知とメニューの表示をする

```vue
<script>
    setBlockObserver(editorId) {
      this.editorId = editorId;
      const editorDiv = document.getElementById(editorId); //holder div
      const elm = editorDiv.getElementsByClassName("codex-editor__redactor")[0]; //ブロックが追加されていくdiv
      const observer = new MutationObserver(
        function() {
          this.setParagraphNodeObserver(); //ブロックが追加されたら子ノードに対してMutation Observer付けるというコールバック
        }.bind(this)
      );
      //要素の追加削除の変更のみ検知する設定
      const config = {
        childList: true
      };
      observer.observe(elm, config); //監視開始
      this.setParagraphNodeObserver();　//Editorjsが初期化時に追加するParagraphブロック対応用に手動で読んでいる
    },
</script>

```
追加されたDomがParagraphブロックなのか調べ、「＠」を検知するObserverを設置

```vue
<script>
    setParagraphNodeObserver() {
      const paragraphNodeObserverConfig = {
        characterData: true, //textContentの変更を見る
        attributes: false, //属性の変更は見ない
        childList: false, //子要素の追加削除は見ない
        subtree: true, //サブツリーまで見る
      };
      const editorDiv = document.getElementById(this.editorId);//holder
      const paragraphNodes = editorDiv.getElementsByClassName("ce-paragraph"); //paragraph dom
      Array.from(paragraphNodes).forEach((pn, pi) => {
        const paragraphNodeObserver = new MutationObserver(
          function(mutations) {
            mutations.forEach(r => {
              const selection = document.getSelection(); //caretの取得
              const regex = RegExp("(\\s|　)@", "g"); //半角・全角スペースの後ろに＠があるか
              const currentCaretPosition = selection.focusOffset; //caretの位置（textContent上での位置になるため、innerHtmlの位置ではない）取得
              const initialMention =
                r.target.data.indexOf("@") === 0 && currentCaretPosition === 1; //行の先頭にあるかのチェック
              const contextMentions = [...r.target.data.matchAll(regex)]; //文中に正規表現にマッチする@があるか
              const isMention =
                initialMention ||
                contextMentions.filter(cm => {
                  return cm.index + cm[0].length === currentCaretPosition; //caretのある位置の@がメンションであるかのチェック
                }).length;
              if (isMention) {
                const rangeRect = selection
                  .getRangeAt(0)
                  .getBoundingClientRect(); //caretの画面上の座標取得
                const { x, y } = rangeRect; //メンションメニューの表示
                this.mentionMenu.selectedParagraphIndex = pi; //何番目のパラグラフなのか一時保存
                this.mentionMenu.x = x;
                this.mentionMenu.y = y;
                this.mentionMenu.show = true;
              } else {
                this.mentionMenu.selectedParagraphIndex = null;
                this.mentionMenu.x = 0;
                this.mentionMenu.y = 0;
                this.mentionMenu.show = false;
              }
            });
          }.bind(this)
        );
        paragraphNodeObserver.observe(pn, paragraphNodeObserverConfig); //監視コールバックとオプションの定義
      });
    },
</script>
```

#メンションメニューからメンションを追加する
メンションメニューに関してはVuetifyにほぼお任せ、座標と表示可否を管理するだけ。対象のユーザーがクリックされた時にクリックイベントからメンション追加メソッドを呼ぶ。

```vue
<script>
    insertMention(user) {
      const editorDiv = document.getElementById(this.editorId); //holder div
      const paragraphNodes = editorDiv.getElementsByClassName("ce-paragraph"); //paragraph div
      Array.from(paragraphNodes).forEach((pn, pi) => {
        //indexで対象パラグラフ特定
        if (this.mentionMenu.selectedParagraphIndex === pi) {
          const selection = document.getSelection(); //caretの取得
          const currentCaretPosition = selection.focusOffset; //caret位置の取得
          if (currentCaretPosition === 1) {
            let text = pn.textContent; //textContent取得
            text = text.slice(1, text.length);
            text =
              `<a href="${user.id}"class="text-decoration-none font-weight-bold">@${user.name}</a>` +
              text;
            pn.innerHTML = text; //メンションタグ追加
            this.addMentionedUser(user.id); //サーバーサイドに送るように別途保存
          } else {
            const node = document.getSelection();
            let targetText = node.anchorNode.data ? node.anchorNode.data : ""; //caretのある位置のtextContentを取得(対象パラグラフの中に他にもアンカータグやイタリックなどが含まれている場合には、キャレットのある位置からタグが見つかる位置までをanchorNodeとして取得してくれる)
            const regex = RegExp("(\\s|　)@", "g");
            let contextMentions = [...targetText.matchAll(regex)];
            const targetPoint = contextMentions.find(cm => {
              return cm.index + cm[0].length === node.anchorOffset;
            });
            if (targetPoint) {
              let before = targetText.slice(0, targetPoint.index);
              let after = targetText.slice(
                targetPoint.index + targetPoint[0].length,
                targetText.length
              );
              const mention =
                before +
                `<a href="${user.id}" data-user-id="${user.id}" class="text-decoration-none font-weight-bold">@${user.name}</a>` +
                after;
              if (pn.innerHTML.match(targetText)) {
                pn.innerHTML = pn.innerHTML.replace(targetText, mention);
              } else {
                //特殊文字やtextContentとinnerHtmlで差異が出る特殊文字などを変換した上で、置換する
                const escapedText = this.escapeHtml(targetText);
                //$nbsp
                const replacedText = escapedText.replace(
                  String.fromCharCode(160),
                  "&nbsp;"
                );
                if (pn.innerHTML.match(replacedText) !== null) {
                  pn.innerHTML = pn.innerHTML.replace(replacedText, mention);
                }
              }
            }
            this.addMentionedUser(user.id);
          }
        }
      });
    },
</script>
```