Prosemirrorでチップ画像をテキストエディタに表示する
始めに
以前テキストエディタにチップ画像を表示させる方法で、contenteditable
を設定して実装する方法を紹介しました。
しかし自前でDOMを操作するのは大変手間でバグも発生しやすく、実装が大変です。テキストエディタをカスタマイズするためのライブラリにProsemirror
というものがあり、こちらを使うと泥臭い実装を任せることができたので、こちらのやり方も紹介したいと思います。
先にサンプルコードを載せますので、動きが気になる方は先に触ってみてください。
Prosemirrorとは
概要
「始めに」で書いたように、テキストエディタをリッチにカスタマイズするためのライブラリです。事前にテキスト構造のスキーマを定義することで、独自で定義した形式を追加することができ、非常にカスタマイズ性に優れています。
Backlogでも取り入れているようです。
最小構成
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ですが、詳細はチップ画像の実装で説明します。
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
に対応しています。
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
にマウントしています。
const view = new EditorView(document.getElementById("app"), {
state
});
Prosemirrorでチップ画像を表示できるようにする
前のセクションでProsemirrorの概要を説明しました。ここからは具体的にチップ画像をテキストエディタに表示させる方法を説明します。
chipスキーマを定義する
まずchipというスキーマを新しく定義します。attrs
プロパティでスキーマ内で管理する変数を定義できます。ここではname
が、デフォルトで「チップ」という文言で持っています。
toDOM
でエディタ上に表示するDOMを設定できます。配列の1つ目がタグ名で、2つ名がDOMのattributeになります。createChipImage
は名前を引数としてcanvasにチップを描いてそれをbase64でURLとして生成するメソッドで、詳細を見たい方は「始めに」で載せているサンプルの方をご参照ください。
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スキーマデータを渡すことができ、そこにチップを含めます。
+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;
};
};
このコードは以下を参考にしました。
後は適当にボタンを用意してクリック時にコマンドを実行するだけです。
const elInsertChipButton = document.getElementById("insertChipButton");
elInsertChipButton?.addEventListener("click", () => {
insertNode(schema.nodes.chip, { name: "チップ" })(
view.state,
view.dispatch,
view
);
});
コピペ時にチップが表示されるようにする
chipスキーマにparseDOM
の設定がされていなかったので最後にそれも設定します。toDOM
で設定したDOM形式をparse時に同じスキーマデータになるようにします。これを設定するとコピペしたときにちゃんとチップもペーストされるようになります。
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だけパースしてくれました^^
独自のテキストエディタを実装する必要になった際に参考になれば幸いです。
参考記事
Discussion