ProseMirrorの概要
始めに
リッチテキストエディタの一つにProseMirrorがあります。これはカスタマイズ性に非常に富んでおり、使いこなせば様々な要望に応えられる反面、ライブラリ特有の概念が多く、その辺の理解がないと使いこなすことが難しいです。
幸い日本語訳がありますが、それでも全てを読んで理解するのは大変だと思います。
そこでCodeSandboxを使って動きを確認しながら、もう少しざっくりとこういう仕組みになっていると分かるようにまとめてみました。
ProseMirrorのモジュール構成
まず始めに、ProseMirrorは複数のパッケージに分かれており、1つのパッケージだけでは完結しません。最低でも以下の3つのパッケージが必要になります。
-
prosemirror-model
- 記事コンテンツのデータ構造を定義するパッケージ
-
prosemirror-state
- エディタの現在の状態(テキストの内容だけでなく、カーソル位置や選択範囲なども含める)を管理するパッケージ
-
prosemirror-view
- 実際にブラウザに編集可能な要素として表示して、ユーザインタラクションのインターフェースとなるパッケージ
これを使って最小構成を実装すると、以下のようなコードになります。Schema
で記事コンテンツのデータ構造を定義して、それを元に最初のエディタの状態をEditorState.create
で作成し、それをEditorView
で指定したDOM配下に表示させます。ここでは#app
のDOM配下に表示させています。
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
で生成されたビューに何かしら入力した場合、トランザクションが生成されます。エディタの状態は直接編集されるのではなく、必ずこのトランザクションを経由して新しい状態を作って更新します。
この処理は標準では暗黙で行われていますが、フックして自前で書く場合は以下のように書きます。
const view = new EditorView(document.getElementById("app"), {
state,
+ dispatchTransaction(transaction) {
+ const newState = view.state.apply(transaction);
+ view.updateState(newState);
+ }
});
このフックが<input type="text" />
のような入力テキストで使うoninput
と似た立ち位置になるため、変更直後に現在の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-historyとprosemirror-keymapがあります。まずhistoryの方は文字通り編集履歴を管理することができて、戻ったり進めたりすることができます。keymapの方は特定のキー入力に後述する実行したいコマンドを指定することができます。historyで戻ったり進めたりという機能はありますが、それを実行するトリガーがないため、keymapと組み合わせることで指定したキーを入力して入力状態を戻ったり進めたりすることができます。
これをコードに書くと、以下のようになります。
// 繰り返しの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-y
とMod-Shift-z
それぞれでできるようにしています。
これを実行すると以下のようになります。エディタを戻したり進めたりできることが確認できると思いますが、HTMLテキストやプレビューもキチンと追従できていることに注目してください。pluginによって状態を変更する操作が入っていますが、それも最終的にはEditorViewにあるdispatchTransaction
でhookされています。
コマンド
コマンドの仕様
前セクションで使ったundo
とredo
はコマンドと呼ばれる特殊な関数になっています。keymap
を経由して実行させたり、メニューから実行したり、何かしらのトリガーから実行させることを想定したものになっています。
実用的な理由から、コマンドは少し特殊なインターフェースになっています。型定義は以下のようになっています。
declare type Command = (
state: EditorState,
dispatch?: (tr: Transaction) => void,
view?: EditorView
) => boolean;
第2引数のdispatch
で変更内容をトランザクションとして発行して送信するのですが、これがオプショナルになっています。これを渡さない場合は実際に変更操作が行われません。 なぜこうなっているかというと、このコマンドが実行できる状態かを事前にチェックするためです。ReturnTypeがbooleanになっていますが、実行可能な場合はtrue、実行不可能な場合はfalseを返すようになっており、実行できない場合はボタンを非活性にするといった処理を書くことができます。コマンドが実行できるかを確認するだけなのに実際に変更操作が行われると困るため、第2引数を空にすることができるようになっています。
この仕様を使って、入力履歴をボタンから操作できるようにすると以下のようなコードになります。
+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
に含まれていますが、他のキーのコマンドも入っています。
このコマンドを使うと以下のようなコードになります。
+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の定義は全部は多いので一部だけ載せますが以下のようになっており、設定しなくてもそこまで困らないようなものがいくつかあります。
もしこういったものが不要で本当に最低限のものだけあれば良いという場合は以下のように書いても問題ないです。
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
を使用し、そこではNode
とMark
について定義します。
- Node
- ドキュメントを構成する要素
- 一行ブロックやテキストなど
- Nodeの中にNodeを持つツリー構造になっており、ルートはdoc Nodeになる
- ドキュメントを構成する要素
- Mark
- 太字や斜体など、テキストのようなインラインコンテンツに対して装飾を与える
Node
Nodeの概要
Nodeはドキュメント内容を表現する骨格となるものなので、最低限定義しなければいけないNodeが存在します。それがdoc
, paragraph
, text
であり、最小構成でも定義されたものです。それぞれ以下の役割を担っています。
- doc
- ドキュメント内容を管理する一番トップのNode
- paragraph
- 一行単位のNode
- text
- テキストを持つNode
これをコードにすると以下のようになります。
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*
にして空でも許可するようにしています。
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
がありますので、それを使います。
+ 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と似ており、parseDOM
やtoDOM
でそのmarkに対して表示するDOMを設定することができます。
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
というコマンドがありますので、それを使います。太字などの装飾はよくショートカットキーでも使うことがあるので、それも設定します。
+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