👋

Prosemirrorでチップ画像をテキストエディタに表示する

2022/10/23に公開

始めに

以前テキストエディタにチップ画像を表示させる方法で、contenteditableを設定して実装する方法を紹介しました。

https://zenn.dev/wintyo/articles/63930256f63c41

しかし自前でDOMを操作するのは大変手間でバグも発生しやすく、実装が大変です。テキストエディタをカスタマイズするためのライブラリにProsemirrorというものがあり、こちらを使うと泥臭い実装を任せることができたので、こちらのやり方も紹介したいと思います。

先にサンプルコードを載せますので、動きが気になる方は先に触ってみてください。

Prosemirrorとは

概要

「始めに」で書いたように、テキストエディタをリッチにカスタマイズするためのライブラリです。事前にテキスト構造のスキーマを定義することで、独自で定義した形式を追加することができ、非常にカスタマイズ性に優れています。

Backlogでも取り入れているようです。
https://nulab.com/ja/blog/backlog/building-text-editor-with-prosemirror/

最小構成

Prosemirrorはオールインワンでは無いため、必要な要素を一つずつ入れる必要があります。
一般的なテキストエディタとして機能させるには以下のような設定が必要になります。

最小構成
import { Schema } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { undo, redo, history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { baseKeymap } from "prosemirror-commands";

const schema = new Schema({
  nodes: {
    doc: {
      content: "block+"
    },
    paragraph: {
      content: "inline*",
      group: "block",
      parseDOM: [{ tag: "p" }],
      toDOM() {
        return ["p", 0];
      }
    },
    text: {
      group: "inline"
    },
  }
});

const state = EditorState.create({
  schema,
  plugins: [
    history(),
    keymap({
      "Mod-z": undo,
      "Mod-y": redo,
      "Mod-Shift-z": redo
    }),
    keymap({
      Enter: baseKeymap["Enter"],
      Backspace: baseKeymap["Backspace"]
    })
  ]
});

const view = new EditorView(document.getElementById("app"), {
  state
});

schemaの定義

Prosemirror上で解釈するスキーマを定義します。docという名前がルートになり、ここを起点に持ちうるデータ構造を定義します。contentプロパティに含めることができるNodeスキーマ名またはグループ名を指定することができます。後ろに+*ついているのは正規表現と同じように「1つ以上」「0以上」という意味になります。
parseDOM, toDOMはエディタ上でDOMをペーストした時の解析/エディタ上に表示するDOMですが、詳細はチップ画像の実装で説明します。

schema定義
import { Schema } from "prosemirror-model";

const schema = new Schema({
  nodes: {
    // 一番親となるスキーマ。「block」というグループを1つ以上持つ
    doc: {
      content: "block+"
    },
    // 段落スキーマ。「block」というグループに所属して、「inline」というグループを0以上持つ
    paragraph: {
      content: "inline*",
      group: "block",
      parseDOM: [{ tag: "p" }],
      toDOM() {
        return ["p", 0];
      }
    },
    // テキストスキーマ。「inline」グループに所属
    text: {
      group: "inline"
    },
  }
});

stateの作成とプラグインの設定

先ほど定義したSchemaを元にエディタの状態を管理するEditorStateを作成します。ここでプラグインも設定するので、合わせて設定しています。
今回設定しているのはやり直し機能と改行・バックスペース削除機能です。これからの設定を入れないと改行すらできないです(泣)。
Prosemirrorはコマンドという形で操作イベントを発行し、それを実行する流れとなっています。keymapはキー入力に応じたコマンドを設定することができ、Mod-Z, Enterなどのキーにそれぞれ対応したコマンドが実行されるように設定しています。Modは修飾子キーのことで、WindowsならCtrl、MacならCmdに対応しています。

stateの作成とプラグインの設定
import { EditorState } from "prosemirror-state";
import { undo, redo, history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { baseKeymap } from "prosemirror-commands";

const state = EditorState.create({
  schema,
  plugins: [
    history(),
    keymap({
      "Mod-z": undo,
      "Mod-y": redo,
      "Mod-Shift-z": redo
    }),
    keymap({
      Enter: baseKeymap["Enter"],
      Backspace: baseKeymap["Backspace"]
    }),
    // baseKeymapで定義されているもの全て入れたい場合は以下のようにする
    // keymap(baseKeymap)
  ]
});

Editorをマウント

最後に生成したstateを元にエディタをマウントさせます。サンプルでは#appにマウントしています。

Editorをマウント
const view = new EditorView(document.getElementById("app"), {
  state
});

Prosemirrorでチップ画像を表示できるようにする

前のセクションでProsemirrorの概要を説明しました。ここからは具体的にチップ画像をテキストエディタに表示させる方法を説明します。

chipスキーマを定義する

まずchipというスキーマを新しく定義します。attrsプロパティでスキーマ内で管理する変数を定義できます。ここではnameが、デフォルトで「チップ」という文言で持っています。
toDOMでエディタ上に表示するDOMを設定できます。配列の1つ目がタグ名で、2つ名がDOMのattributeになります。createChipImageは名前を引数としてcanvasにチップを描いてそれをbase64でURLとして生成するメソッドで、詳細を見たい方は「始めに」で載せているサンプルの方をご参照ください。

chipスキーマの定義
 const schema = new Schema({
   nodes: {
     doc: {
       content: "block+"
     },
     paragraph: {
       content: "inline*",
       group: "block",
       parseDOM: [{ tag: "p" }],
       toDOM() {
         return ["p", 0];
       }
     },
     text: {
       group: "inline"
     },
+    chip: {
+      inline: true,
+      attrs: {
+        name: { default: "チップ" }
+      },
+      group: "inline",
+      draggable: true,
+      toDOM(node) {
+        const chipImageUrl = createChipImage(node.attrs.name);
+        return [
+          "img",
+          {
+            src: chipImageUrl,
+            "data-chip-name": node.attrs.name,
+            style: "height: 25px"
+          }
+        ];
+      }
+    }
   }
 });

チップを初期値に含めて確認できるようにする

先ほど定義したスキーマを使って、初期値にチップが含んだ状態のものを表示したいと思います。
stateを生成するときにdocプロパティに初期のNodeスキーマデータを渡すことができ、そこにチップを含めます。

チップNodeを初期値に含める
+const doc = schema.node("doc", null, [
+  schema.node("paragraph", null, [schema.text("Hello!"), schema.node("chip")])
+]);

 const state = EditorState.create({
+  doc,
   schema,
   plugins: [
     history(),
     keymap({
       "Mod-z": undo,
       "Mod-y": redo,
       "Mod-Shift-z": redo
     }),
     keymap({
       Enter: baseKeymap["Enter"],
       Backspace: baseKeymap["Backspace"]
     })
   ]
 });

こんな感じのが表示されたと思います。

コマンド経由でチップを挿入できるようにする

先ほどの内容で最低限チップを表示できるようになりました。ただ新しくチップを挿入することはできないので、その機能を作っていきます。

まずは挿入コマンドを作ります。

チップ挿入コマンド
import { NodeType, Attrs } from "prosemirror-model";
import { EditorState, Command } from "prosemirror-state";

const canInsertNode = (state: EditorState, nodeType: NodeType) => {
  const $from = state.selection.$from;
  for (let d = $from.depth; d >= 0; d--) {
    const index = $from.index(d);
    if ($from.node(d).canReplaceWith(index, index, nodeType)) {
      return true;
    }
  }
  return false;
};

const insertNode = (nodeType: NodeType, attrs?: Attrs): Command => {
  return (state, dispatch, view) => {
    if (!canInsertNode(state, nodeType)) {
      return false;
    }
    const node = nodeType.createAndFill(attrs);
    if (node == null) {
      return false;
    }
    if (dispatch && view) {
      dispatch(state.tr.replaceSelectionWith(node));
      view.focus();
    }
    return true;
  };
};

このコードは以下を参考にしました。
https://github.com/ProseMirror/prosemirror-example-setup/blob/1.2.1/src/menu.ts#L11-L44

後は適当にボタンを用意してクリック時にコマンドを実行するだけです。

chip挿入コマンドの実行
const elInsertChipButton = document.getElementById("insertChipButton");

elInsertChipButton?.addEventListener("click", () => {
  insertNode(schema.nodes.chip, { name: "チップ" })(
    view.state,
    view.dispatch,
    view
  );
});

コピペ時にチップが表示されるようにする

chipスキーマにparseDOMの設定がされていなかったので最後にそれも設定します。toDOMで設定したDOM形式をparse時に同じスキーマデータになるようにします。これを設定するとコピペしたときにちゃんとチップもペーストされるようになります。

parseDOMの設定
 const schema = new Schema({
   nodes: {
     // 省略
     chip: {
       inline: true,
       attrs: {
         name: { default: "チップ" }
       },
       group: "inline",
       draggable: true,
+      parseDOM: [
+        {
+          tag: "img[data-chip-name]",
+          getAttrs(dom) {
+            if (typeof dom === "string") {
+              return false;
+            }
+            return {
+              name: dom.getAttribute("data-chip-name")
+            };
+          }
+        }
+      ],
       toDOM(node) {
         const chipImageUrl = createChipImage(node.attrs.name);
         return [
           "img",
           {
             src: chipImageUrl,
             "data-chip-name": node.attrs.name,
             style: "height: 25px"
           }
         ];
       }
     }
   }
 });

終わりに

以上がProsemirrorを使ったチップ画像の表示でした。最初にライブラリの理解が結構必要で大変ですが、一度理解すると拡張性が非常に高いので、色々カスタマイズしたい場合は便利だと思いました。前に紹介した自前のチップ表示だとコピペするとエディタに関係ないDOMもペーストできる問題がありましたが、こちらはしっかりと必要なDOMだけパースしてくれました^^
独自のテキストエディタを実装する必要になった際に参考になれば幸いです。

参考記事

https://zenn.dev/mh4gf/articles/d25ef1ff30b5a6
https://nulab.com/ja/blog/backlog/building-text-editor-with-prosemirror/

Discussion