📌

ProseMirrorの概要

2023/01/16に公開

始めに

リッチテキストエディタの一つにProseMirrorがあります。これはカスタマイズ性に非常に富んでおり、使いこなせば様々な要望に応えられる反面、ライブラリ特有の概念が多く、その辺の理解がないと使いこなすことが難しいです。
幸い日本語訳がありますが、それでも全てを読んで理解するのは大変だと思います。

https://zenn.dev/mh4gf/articles/d25ef1ff30b5a6

そこでCodeSandboxを使って動きを確認しながら、もう少しざっくりとこういう仕組みになっていると分かるようにまとめてみました。

ProseMirrorのモジュール構成

まず始めに、ProseMirrorは複数のパッケージに分かれており、1つのパッケージだけでは完結しません。最低でも以下の3つのパッケージが必要になります。

  • prosemirror-model
    • 記事コンテンツのデータ構造を定義するパッケージ
  • prosemirror-state
    • エディタの現在の状態(テキストの内容だけでなく、カーソル位置や選択範囲なども含める)を管理するパッケージ
  • prosemirror-view
    • 実際にブラウザに編集可能な要素として表示して、ユーザインタラクションのインターフェースとなるパッケージ

これを使って最小構成を実装すると、以下のようなコードになります。Schemaで記事コンテンツのデータ構造を定義して、それを元に最初のエディタの状態をEditorState.createで作成し、それをEditorViewで指定したDOM配下に表示させます。ここでは#appのDOM配下に表示させています。

ProseMirrorの最小構成
import { Schema } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";

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
});

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

これを実行した結果はこちらになります。最低限テキスト入力ができるようになりましたが、Enterキーを押しても改行することができません。

トランザクション

EditorViewで生成されたビューに何かしら入力した場合、トランザクションが生成されます。エディタの状態は直接編集されるのではなく、必ずこのトランザクションを経由して新しい状態を作って更新します。
この処理は標準では暗黙で行われていますが、フックして自前で書く場合は以下のように書きます。

transactionsのフック
 const view = new EditorView(document.getElementById("app"), {
   state,
+  dispatchTransaction(transaction) {
+    const newState = view.state.apply(transaction);
+    view.updateState(newState);
+  }
 });

このフックが<input type="text" />のような入力テキストで使うoninputと似た立ち位置になるため、変更直後に現在のHTMLテキストやプレビューを表示しようとすると以下のようなコードになります。

入力状態のHTMLテキストやプレビューを表示
+const elHtmlText = document.getElementById("htmlText") as HTMLElement;
+const elPreview = document.getElementById("preview") as HTMLElement;
 const view = new EditorView(document.getElementById("app"), {
   state,
   dispatchTransaction(transaction) {
     const newState = view.state.apply(transaction);
     view.updateState(newState);
+    elHtmlText.textContent = view.dom.innerHTML;
+    elPreview.innerHTML = view.dom.innerHTML;
   }
 });

これを実行すると以下のようになります。

プラグイン

ProseMirrorで機能を拡張させる手段の一つにプラグインがあります。プラグインではエディタの振る舞いやエディタの状態を拡張させることができますが、既に公開されていてよく使うプラグインとしてprosemirror-historyprosemirror-keymapがあります。まずhistoryの方は文字通り編集履歴を管理することができて、戻ったり進めたりすることができます。keymapの方は特定のキー入力に後述する実行したいコマンドを指定することができます。historyで戻ったり進めたりという機能はありますが、それを実行するトリガーがないため、keymapと組み合わせることで指定したキーを入力して入力状態を戻ったり進めたりすることができます。
これをコードに書くと、以下のようになります。

historyプラグインを使う
 // 繰り返しのimportは省略
+import { history, undo, redo } from "prosemirror-history";
+import { keymap } from "prosemirror-keymap";

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

ModはWindowsだとCtrl、MacだとCmdキーと紐づいており、WindowsだとCtrl+z、MacだとCmd+zで戻ることができます。同じコマンドを別々なキーに設定することができるため、やり直しはMod-yMod-Shift-zそれぞれでできるようにしています。

これを実行すると以下のようになります。エディタを戻したり進めたりできることが確認できると思いますが、HTMLテキストやプレビューもキチンと追従できていることに注目してください。pluginによって状態を変更する操作が入っていますが、それも最終的にはEditorViewにあるdispatchTransactionでhookされています。

コマンド

コマンドの仕様

前セクションで使ったundoredoはコマンドと呼ばれる特殊な関数になっています。keymapを経由して実行させたり、メニューから実行したり、何かしらのトリガーから実行させることを想定したものになっています。
実用的な理由から、コマンドは少し特殊なインターフェースになっています。型定義は以下のようになっています。

Commandのインターフェース
declare type Command = (
  state: EditorState,
  dispatch?: (tr: Transaction) => void,
  view?: EditorView
) => boolean;

第2引数のdispatchで変更内容をトランザクションとして発行して送信するのですが、これがオプショナルになっています。これを渡さない場合は実際に変更操作が行われません。 なぜこうなっているかというと、このコマンドが実行できる状態かを事前にチェックするためです。ReturnTypeがbooleanになっていますが、実行可能な場合はtrue、実行不可能な場合はfalseを返すようになっており、実行できない場合はボタンを非活性にするといった処理を書くことができます。コマンドが実行できるかを確認するだけなのに実際に変更操作が行われると困るため、第2引数を空にすることができるようになっています。

この仕様を使って、入力履歴をボタンから操作できるようにすると以下のようなコードになります。

ボタンからCommandを実行する
+const elPrevButton = document.getElementById("prevButton") as HTMLButtonElement;
+const elNextButton = document.getElementById("nextButton") as HTMLButtonElement;

 const elHtmlText = document.getElementById("htmlText") as HTMLElement;
 const elPreview = document.getElementById("preview") as HTMLElement;
 const view = new EditorView(document.getElementById("app"), {
   state,
   dispatchTransaction(transaction) {
     const newState = view.state.apply(transaction);
     view.updateState(newState);
     elHtmlText.textContent = view.dom.innerHTML;
     elPreview.innerHTML = view.dom.innerHTML;

+    elPrevButton.disabled = !undo(view.state);
+    elNextButton.disabled = !redo(view.state);
   }
 });

+elPrevButton.addEventListener("click", () => {
+  undo(view.state, view.dispatch, view);
+  view.focus();
+});
+elNextButton.addEventListener("click", () => {
+  redo(view.state, view.dispatch, view);
+  view.focus();
+});

これを実行すると以下のようになります。

改行用のコマンドを適応

最小構成の頃からEnterによる改行が対応されていませんでしたが、それに対応したコマンドがパッケージで公開されています。それはprosemirror-commandsというパッケージ中のbaseKeymapに含まれていますが、他のキーのコマンドも入っています。
このコマンドを使うと以下のようなコードになります。

baseKeyMapを使う
+import { baseKeymap } from "prosemirror-commands";

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

baseKeyMapの定義は全部は多いので一部だけ載せますが以下のようになっており、設定しなくてもそこまで困らないようなものがいくつかあります。

https://github.com/ProseMirror/prosemirror-commands/blob/1.5.0/src/commands.ts#L690-L699

もしこういったものが不要で本当に最低限のものだけあれば良いという場合は以下のように書いても問題ないです。

最低限のbaseKeyMapだけ設定する
 const state = EditorState.create({
   schema,
   plugins: [
     history(),
     keymap({
       "Mod-z": undo,
       "Mod-y": redo,
       "Mod-Shift-z": redo
     }),
-    keymap(baseKeymap)
+    keymap({
+      Enter: baseKeymap.Enter,
+      Backspace: baseKeymap.Backspace,
+      Delete: baseKeymap.Delete
+    })
   ]
 });

これを実行すると以下のようになります。これでようやく基本的な動作ができるエディタになりました。

ドキュメント(記事コンテンツのデータ構造)

ProseMirrorはドキュメント内容を表現する独自のデータ構造を定義します。この定義によって様々なリッチテキストを表現することができます。

大まかな構成

ドキュメント内容のデータ構造の定義はSchemaを使用し、そこではNodeMarkについて定義します。

  • Node
    • ドキュメントを構成する要素
      • 一行ブロックやテキストなど
    • Nodeの中にNodeを持つツリー構造になっており、ルートはdoc Nodeになる
  • Mark
    • 太字や斜体など、テキストのようなインラインコンテンツに対して装飾を与える

Node

Nodeの概要

Nodeはドキュメント内容を表現する骨格となるものなので、最低限定義しなければいけないNodeが存在します。それがdoc, paragraph, textであり、最小構成でも定義されたものです。それぞれ以下の役割を担っています。

  • doc
    • ドキュメント内容を管理する一番トップのNode
  • paragraph
    • 一行単位のNode
  • text
    • テキストを持つNode

これをコードにすると以下のようになります。

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

ここで詳細なプロパティの内容を説明します。contentはこのNodeに含めることができるNode名またはグループ名を記述します。グループ名はgroupプロパティで設定することができ、まとめて指定するときに役立ちます。従ってdocの定義はcontent: 'paragraph+'と指定しても問題ありません。後ろの+*は正規表現と同じように1個以上か、0個以上かを表現しています。よってdoc Nodeはblockグループであるノードが1個以上含める必要があり、paragraph Nodeはinlineグループであるノードが0個以上含める必要があるという風な定義になっております。
これをUMLで表すと以下のような関係になっています。

toDOMは実際にHTMLに出力する際の定義になります。配列で表現することができ、最初が表示したいタグ名になります。paragraph Nodeではpタグを指定しており、次に0を設定しているのはProseMirrorの仕様で子要素を表示させるという意味になっているためです。
parseDOMはコピーアンドペーストなどでHTMLテキストが挿入された際にどのように解析するかを指定するものになっています。paragraph Nodeはpタグで表現されているため、parseDOMもpタグのものがマッチされるように指定しています。

余談

最小構成ではEnterキーで改行することができませんでしたが、これはEnterキー入力時に新しくparagraph Nodeを作る必要があったからです。この操作がbaseKeymap.Enterコマンドに含まれており、これを実行することで始めて改行が表現できます。

NodeSpecの追加(引用ブロックを追加してみる)

理解を深めるために引用ブロックを追加してみます。先ほどまで話していたSchema内のnodesはNodeSpecであるため、まずはこのNodeSpecを追加します。doc Nodeがblock+のままだと初期表示でエラーになってしまったのでblock*にして空でも許可するようにしています。

引用ブロックのNodeSpecを追加
 const schema = new Schema({
   nodes: {
     doc: {
-      content: "block+"
+      content: "block*"
     },
+    blockquote: {
+      content: "block+",
+      group: "block",
+      parseDOM: [{ tag: "blockquote" }],
+      toDOM() {
+        return ["blockquote", 0];
+      }
+    },
     // 他NodeSpecは省略
   }
 });

NodeSpecを定義したので、あとはこれをコマンド経由で実行します。NodeをラップするコマンドはwrapInがありますので、それを使います。

wrapInコマンドを使って引用ブロックを表示する
+ import { baseKeymap, wrapIn } from "prosemirror-commands";

// 一部省略

+const elBlockQuoteButton = document.getElementById(
+  "blockQuoteButton"
+) as HTMLButtonElement;
+// 引用ブロックコマンドにして使いやすくする
+const wrapInBlockQuote = wrapIn(schema.nodes.blockquote);

 const view = new EditorView(document.getElementById("app"), {
   state,
   dispatchTransaction(transaction) {
     const newState = view.state.apply(transaction);
     view.updateState(newState);
     elHtmlText.textContent = view.dom.innerHTML;
     elPreview.innerHTML = view.dom.innerHTML;

+    elBlockQuoteButton.disabled = !wrapInBlockQuote(view.state);
     elPrevButton.disabled = !undo(view.state);
     elNextButton.disabled = !redo(view.state);
   }
 });

+elBlockQuoteButton.addEventListener("click", () => {
+  wrapInBlockQuote(view.state, view.dispatch, view);
+  view.focus();
+});

これを実行すると以下のようになります。

Mark

Markの概要

Markは太字や斜体など、テキストのようなインラインNodeに対して装飾を与えます。Nodeと違って必須なものはありません。

MarkSpecの追加(太字、斜体、下線装飾を追加してみる)

例として太字、斜体、下線の装飾を追加してみます。設定方法はNodeSpecと似ており、parseDOMtoDOMでそのmarkに対して表示するDOMを設定することができます。

MarkSpecの定義
const schema = new Schema({
  nodes: {
    // NodeSpecの定義は省略
  },
  marks: {
    bold: {
      parseDOM: [{ tag: "b" }],
      toDOM() {
        return ["b", 0];
      }
    },
    italic: {
      parseDOM: [{ tag: "i" }],
      toDOM() {
        return ["i", 0];
      }
    },
    underline: {
      parseDOM: [{ tag: "u" }],
      toDOM() {
        return ["u", 0];
      }
    }
  }
});

MarkSpecを定義したので、Nodeの時と同じようにコマンド経由で装飾が付けられるようにします。toggleMarkというコマンドがありますので、それを使います。太字などの装飾はよくショートカットキーでも使うことがあるので、それも設定します。

toggleMarkコマンドを使って太字の装飾をする
+import { baseKeymap, wrapIn, toggleMark } from "prosemirror-commands";

 // 一部省略

+const toggleBoldMark = toggleMark(schema.marks.bold);
+const toggleItalicMark = toggleMark(schema.marks.italic);
+const toggleUnderlineMark = toggleMark(schema.marks.underline);

 const state = EditorState.create({
   schema,
   plugins: [
     // 他のプラグインは省略
+    keymap({
+      "Mod-b": toggleBoldMark,
+      "Mod-i": toggleItalicMark,
+      "Mod-u": toggleUnderlineMark
+    })
   ]
 });
 
+const elBoldButton = document.getElementById("boldButton") as HTMLButtonElement;
+const elItalicButton = document.getElementById("italicButton") as HTMLButtonElement;
+const elUnderlineButton = document.getElementById("underlineButton") as HTMLButtonElement;

 const view = new EditorView(document.getElementById("app"), {
   state,
   dispatchTransaction(transaction) {
     const newState = view.state.apply(transaction);
     view.updateState(newState);
     elHtmlText.textContent = view.dom.innerHTML;
     elPreview.innerHTML = view.dom.innerHTML;

+    elBoldButton.disabled = !toggleBoldMark(view.state);
+    elItalicButton.disabled = !toggleItalicMark(view.state);
+    elUnderlineButton.disabled = !toggleUnderlineMark(view.state);
     elBlockQuoteButton.disabled = !wrapInBlockQuote(view.state);
     elPrevButton.disabled = !undo(view.state);
     elNextButton.disabled = !redo(view.state);
   }
 });
 
+elBoldButton.addEventListener("click", () => {
+  toggleBoldMark(view.state, view.dispatch, view);
+  view.focus();
+});
+elItalicButton.addEventListener("click", () => {
+  toggleItalicMark(view.state, view.dispatch, view);
+  view.focus();
+});
+elUnderlineButton.addEventListener("click", () => {
+  toggleUnderlineMark(view.state, view.dispatch, view);
+  view.focus();
+});

これを実行すると以下のようになります。

データ構造

NodeSpecではツリー構造のような定義をしていますが、実際のデータ構造はフラットなシーケンスでモデル化され、MarkはメタデータとしてNodeに付与されます。
これがどういうことかHTMLと比較して説明します。例えば以下のようなHTMLがあったとします。

<p>テキスト<b>太字<i>かつ斜体</i></b></p>

この時、HTMLのツリー構造は以下のようになります。

一方、ProseMirrorは以下のようにフラットな構成になり、Markは配列で持つことになります。

こうすることでテキストの参照がやりやすくなります。目的のテキストへの参照をツリーから降りるのではなく単純にテキストのオフセットで表現できますし、厄介なツリー操作なしに分割やコンテンツのスタイル変更の操作ができます。

終わりに

以上がProseMirrorの概要でした。それなりのボリュームにはなっていますが、それでも以下のような話は端折っております。

  • EditorViewへの初期テキストの設定
  • Node, Markに情報を持たせられるAttrs(リンク装飾でurlを持っておきたいなど)やその他細かい設定
  • コマンドの作り方
  • プラグインの作り方
  • メニューの作り方

詳細について知りたい場合は公式ドキュメントなどをご参照してもらえればと思いますが、ProseMirrorがどういうものかのイメージに役立てられたら幸いです。

Discussion