Rich Text EditorのライブラリであるTiptapReMirrorを聞いたことがありますか?どちらもProseMirrorを元(ProseMirrorを簡単に利用するためのWrapper)に作成されているRich Text Editorです。

ProseMirrorを利用する予定はないがProseMirrorの設定方法を知りたい人、TitapやRemirrorを利用する予定があるのでその元になるProseMirrorがどのようなものか知っておきたいという人を対象に説明を行っています。

環境の構築

プロジェクトの作成

Viteを利用してVanilla JavaScriptを利用して動作確認を行っています。プロジェクト名は任意の名前をつけることができるのでここではmy-prosemirrorという名前にしています。


% npm create vite@latest

> npx
> create-vite

✔ Project name: … my-prosemirror
✔ Select a framework: › Vanilla
✔ Select a variant: › JavaScript

Scaffolding project in /Users/mac/Desktop/my-prosemirror...

Done. Now run:

  cd my-prosemirror
  npm install
  npm run dev

コマンド実行後に作成されるプロジェクトディレクトリに移動してnpm installコマンドを実行してJavaScriptライブラリのインストールを行います。

ProseMirrorライブラリのインストール

ProseMirrorを動作させるために3つのコアライブラリ(prosemirror-model, prosemirror-state, prosemirror-view)のインストールを行います。prosemirror-modelはエディターの構造を定義するスキーマの作成に関するライブラリ、prosemirror-stateはエディターの状態の管理を行うライブラリ、prosemirror-viewはエディターの状態からブラウザ上に表示させるために利用するライブラリです。


% npm install prosemirror-model prosemirror-state prosemirror-view

おそらく大半の人は最初はスキーマとは何かがわかりくいかと思います。スキーマがなぜ必要になるか簡単に説明すると

ProseMirrorの設定

コアライブラリのインストールが完了したらProseMirrorをブラウザ上でEditorとして動作させるための設定を行っていきます。

最も基本的な設定

main.jsファイルの中で設定を行っていきます。インストールした3つのコアライブラリをすべて利用します。

エディターは文書を扱うため文書がどのような要素で構成されているのかが重要になります。段落や見出し、画像や文書内の文字列に太字やイタリック文字が文書を構成する要素となります。スキーマでは文書にどのような種類のノード(段落や見出し、画像)やマーク(太字、イタリック文字)が含まれているかを事前に定義しておく必要があります。
fukidashi

prosemirror-modelからimportしたSchemaではドキュメントを構成するノード(段落、見出し、画像など)やマーク(太字やイタリック文字など)を定義します。定義がなければブラウザ上にエディタービュー(Editor View)が表示されることはありません。定義したスキーマを利用してエディターの状態を保持するEditorStateのインスタンスを作成します。EditorViewを利用してdocment.getElementByIdで指定した要素に作成したEditorStateのインスタンスをマウントしてエディタービューの表示が行われます。


import { Schema } from 'prosemirror-model';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';

const schema = new Schema({
  nodes: {},
});
const state = EditorState.create({ schema });

new EditorView(document.getElementById('app'), { state });

“npm run dev”コマンドで開発サーバを起動してブラウザからアクセスするとブラウザ上には何も表示されずブラウザのデベロッパーツールのコンソールには”Uncaught RangeError: Schema is missing its top node type (‘doc’)”のエラーが表示されます。Schemaのnodesのトップにはdocタイプのノードが必要であることがわかります。エラーの通りdocを設定します。


const schema = new Schema({
  nodes: {
    doc: {},
  },
});

docの設定後は”Uncaught RangeError: Every schema needs a ‘text’ type”のエラーが表示され、すべてのスキーマにはtextタイプのノードが必要であることがわかります。textノードを利用して以下のコードを記述します。


const schema = new Schema({
  nodes: { doc: { content: 'text*' }, text: {} },
});

PromeMirrorのドキュメント(doc)はツリー構造をしています。コードではdocの子ノードにはtextタイプのノードをゼロ以上の持っているということを表しています。ゼロ以上というのはcontentプロパティに設定したtextの横の*(アスタリスク)が表しています。設定によりdocにはゼロ以上のtextタイプのノードを持っているコンテンツで構成されているということになります。

ブラウザ上には何も表示されていませんが、ブラウザのデベロッパーツールのコンソールの要素を見るとdiv要素が追加されていることがわかります。div要素にはcontenteditable属性が設定されていることも確認できます。要素にcontenteditable属性を設定することでinput、textarea要素を利用することなく要素の中に文字を入力することができるようになります。

設定により追加されたdiv要素の確認
設定により追加されたdiv要素の確認

何も表示されていないブラウザ上にカーソルを合わせるとcontenteditableによりdiv要素の中に文字が入力できるようになります。これがProseMirrorのエディタービューです。

div要素への文字の入力
div要素への文字の入力

文字を入力できるようになったので次は入力した文字がdiv要素ではなく段落(paragraph)を意味するp要素の中に文字列が表示されるようにスキーマの設定を行います。段落を追加することは文書の構造に変更が行われるためスキーマの中で段落を定義する必要があります。

先ほど設定していたdocのcontentプロパティの値をtextからparagraphに変更します。contentはtextではなくparagraphノードから構成されているということを指定しています。さらにparagrapノードの中に子ノードとしてtextノードが含まれるように設定しています。docノードの下にparagraphノードが含まれparagraphノードの下にtextノードが含まれるという構造になります。

toDoM関数ではparagraphノードがブラウザ上ではpタグで表示されるように設定しています。textノードではtoDOM関数がなくてもテキストとしてブラウザ上に表示されるようになっているため設定が必須ではありませんがノードを追加する際はtoDom関数が必要になります。


import { Schema } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";

const schema = new Schema({
  nodes: {
    doc: {
      content: "paragraph+",
    },
    paragraph: {
      content: "text*",
      toDOM() {
        return ["p", 0];
      },
    },
    text: {},
  },
});
const state = EditorState.create({ schema });
new EditorView(document.getElementById("app"), { state });

設定後は入力した文字はpタグで表示されていることがコンソールの要素からわかります。

入力したコンテンツがpタグで表示
入力したコンテンツがpタグで表示

paragraphノードのtoDOM関数ではpタグとして設定しましたが他のタグに設定することも可能です。


const schema = new Schema({
  nodes: {
    doc: {
      content: 'paragraph+',
    },
    paragraph: {
      content: 'text*',
      toDOM() {
        return ['h1', 0];
      },
    },
    text: {},
  },
});

toDOM関数を変更するだけで表示されるブラウザ上での表示スタイルが変わりました。このようにスキーマの設定により文書の構造や表示されるスタイルを自由に設定することができます。

toDOM関数の設定をpからh1に変更した場合
toDOM関数の設定をpからh1に変更した場合

paragraphという名前のノードを利用しましたがスキーマに関するノードやマークにはPromseMirrorのドキュメントのhttps://prosemirror.net/docs/ref/#schema-basicに記述されています。

スキーマ内のparagraphという名前をparaと変更しても動作します。
fukidashi

改行方法の確認

ブラウザ上で文字列を入力することできるようになりましたが改行を行うためrEnterボタンを押しても改行が行われることはありません。改行を行うためには設定を行う必要があります。

改行を行うためにprosemirror-keymap, prosemirror-commandsの2つのライブラリをインストールします。


% npm install prosemirror-keymap prosemirror-commands

インストールしたprosemirror-keymapはキーボードショートカットを定義するために利用します。例えばWindowsでCtrl+Bボタンを押して選択した文字列を太字にしたい場合はprosemirror-keymapでキーボードショートカットの定義を行います。prosemirror-commandsはProseMirror上で利用できるコマンド(関数)を提供するライブラリです。prosemirror-keymapはキーボードショートカットを定義を行うために利用するものなのでprosemirror-commandsから提供される関数(文字列を太字に)と組み合わせることでCtrl+Bボタンをクリックすると太字になるという機能を実装することができます。この機能は後ほど実装します。

ProseMirrorでは機能の拡張を行うたい場合にPluginsを利用します。改行を実装したい場合はインストールした2つのライブラリを利用してpluginsの設定を行います。


import { Schema } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { keymap } from "prosemirror-keymap";
import { baseKeymap } from "prosemirror-commands";

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

const state = EditorState.create({ schema, plugins: [keymap(baseKeymap)] });
new EditorView(document.getElementById("app"), { state });

baseKeymapの中には新しい段落(paragraph)を作成するコマンド(関数)が含まれており、コマンドとキーボードショートカットキーの紐付けがkeymapで行われているためEnterボタンを押すと改行が可能となります。

実際にエディタービューでEnterボタンを押すと新しいpタグが追加されていることがわかります。改行後に作成されるpタグに新たに文字列を入力することができます。

改行の確認
改行の確認

太字(Bold)設定

改行ではキーボードショートカットの設定は行いませんでしたがキーボードショートカットとコマンドを組み合わせることでエディタービューに入力した文字列を太字にすることができます。

太字を表示するためにはまずスキーマに定義を追加する必要があります。スキーマでは段落や見出しのブロック要素やリンクや画像のインライン要素はnodesに設定しますが、太字やイタリックなどの装飾に関するインライン要素はmarksで設定を行っていきます。


const schema = new Schema({
  nodes: {
    doc: {
      content: 'paragraph+',
    },
    paragraph: {
      content: 'text*',
      toDOM() {
        return ['p', 0];
      },
    },
    text: {},
  },
  marks: {
    strong: {
      toDOM() {
        return ['b', 0];
      },
    },
  },
});

スキーマの定義が完了したら通常の文字列を太字にする処理を設定する必要があります。太字にする処理ではprosemirror-keymapを利用してキーボードショートカットの定義を行い、prosemirror-commandsが提供するtoggleMark関数を紐づけます。toggleMark関数は文字列が太字設定されていない場合には太字へ太字設定されている場合は通常のテキストに戻す関数です。

customKeymap変数に設定を保存してpluginsに追加します。


import { Schema } from 'prosemirror-model';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { keymap } from 'prosemirror-keymap';
import { baseKeymap, toggleMark } from 'prosemirror-commands';

const schema = new Schema({
  nodes: {
//略
  marks: {
    strong: {
      toDOM() {
        return ['b', 0];
      },
    },
  },
});

const customKeymap = keymap({
  'Mod-b': toggleMark(schema.marks.strong),
});
const state = EditorState.create({
  schema,
  plugins: [customKeymap, keymap(baseKeymap)],
});

new EditorView(document.getElementById('app'), { state });

customKeymap関数で設定を行っているkeymapの引数で行っている設定では”Mod-b”とtoggleMark関数を紐づけています。”Mod-b”はmacOSではCommand+B, WindowsではCtrl+Bに対応するショートカットキーの設定です。toggleMarkの引数にはスキーマで定義したmarksのstrongを指定しています。

エディタービューで”はじめてのProseMirror”を入力して”ProseMirror”という文字列のみ選択してMacでは”Command+B”、Windowsでは”Ctrl+B”のキーを押すと太字として表示されます。コンソールの要素を見るとスキーマのmarksのstrongのtoDOM関数で設定したbタグが設定されていることがわかります。

文字列を選択してCtrl+BまたはCommand+Bを実行
文字列を選択してCtrl+BまたはCommand+Bを実行

toDOM関数でbタグを設定しましたが太字であればstrongタグも利用できるのでスキーマのbをstrongに変更することも可能です。またstrongというマークの名前をboldに変更することも可能です。その場合はtoggleMarkの引数も変更する必要があります。設定を変更しても同じように動作します。


const schema = new Schema({
//略
  marks: {
    bold: {
      toDOM() {
        return ['strong', 0];
      },
    },
  },
});

const customKeymap = keymap({
  'Mod-b': toggleMark(schema.marks.bold),
});

太字の設定が理解できればイタリックの設定は簡単で以下のようにmarksにemを追加して太字とは別のキーボードショートカットキー(Mod-i)を追加することでmacOSでは”Command+I”、Windowsでは”Ctrl+I”で選択した文字列をイタリック表示にすることができます。


const schema = new Schema({
  nodes: {
    doc: {
      content: 'paragraph+',
    },
    paragraph: {
      content: 'text*',
      toDOM() {
        return ['p', 0];
      },
    },
    text: {},
  },
  marks: {
    strong: {
      toDOM() {
        return ['b', 0];
      },
    },
    em: {
      toDOM() {
        return ['i', 0];
      },
    },
  },
});

const customKeymap = keymap({
  'Mod-b': toggleMark(schema.marks.strong),
  'Mod-i': toggleMark(schema.marks.em),
});

見出しの設定

太字やイタリック表示などインライン要素に対する設定の方法は理解できたのでブロック要素の見出し(heading)を設定する方法を確認します。

Block要素なのでheadingの設定はmarksではなくnodesの下に設定を行い、キーボードショートカットはCtrl+Atl+1(macOSではCtrl+Option+1)に設定、Block要素の変更に利用する関数はtoggleMarkではなくsetBlockTypeを利用します。


const schema = new Schema({
  nodes: {
    doc: {
      content: 'paragraph+',
    },
    paragraph: {
      content: 'text*',
      toDOM() {
        return ['p', 0];
      },
    },
    heading: {
      attrs: { level: { default: 1 } },
      content: 'text*',
      toDOM(node) {
        return ['h' + node.attrs.level, 0];
      },
    },
    text: {},
  },
  marks: {
    strong: {
      toDOM() {
        return ['b', 0];
      },
    },
    em: {
      toDOM() {
        return ['i', 0];
      },
    },
  },
});

const customKeymap = keymap({
  'Mod-b': toggleMark(schema.marks.strong),
  'Mod-i': toggleMark(schema.marks.em),
  'Ctrl-Alt-1': setBlockType(schema.nodes.heading, { level: 1 }),
});

上記のスキーマの設定では段落を見出しに変更することはできません。スキーマを見るとdocのcontentはparagraph+になっているのでdocノードのcontentはparagraphノードのみで構成されることになります。headinノードもdocのcontentに含めるためにはdocのcontentをparagraphからblockに変更してparagraphとheadingにgroupプロパティでblockを設定します。これでdocノードはblockのグループの一員であるparagraphノードとheadingノードで構成することができるようになります。

設定後は文字列を入力後Ctrl+Atl+1(macOSではCtrl+Option+1)を押すとh1タグとして表示されます。

h1タグとして表示
h1タグとして表示

スキーマの設定でparagraphとheadingを利用することができるようになりましたがデフォルトでは文字を入力すると必ずparagraphとしてpタグで行が作成されます。これはスキーマで設定している順番がparagraph、headingとなっているためデフォルトはparagraphとなります。順番をheading, paragraphに変更するとエディタービューで文字を入力するとh1タグで表示されるようになります。

ここまでの設定でスキーマの記述方法やキーボードショートカットキーの設定についての理解が深まったかと思います。

ボタンによりスタイルの変更

キーボードショートカットキーを利用してスタイルの変更を行なっていきましたがここではRich Text Editorで見られるようなメニューボタンによるスタイルの設定方法を確認していきます。

index.htmlファイルにbutton要素を追加してid属性にはboldBtnを設定します。


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div>
      <button id="boldBtn">Bold</button>
    </div>
    <div id="app"></div>
    <script type="module" src="/main.js"></script>
  </body>
</html>

document.getelementByIdメソッドを利用して追加したbutton要素を取得してイベントリスナーでclickイベントのを設定してtoggleMark関数を実行します。toggleMark(schema.marks.strong)を実行すると関数が戻されるのでその関数の引数にはview.state, view.dispatchを設定します。必須ではありませんがtoggleMarkの後にview.focusを設定することでにエディタービューにフォーカスが当たった状態になります。


import { Schema } from 'prosemirror-model';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { keymap } from 'prosemirror-keymap';
import { baseKeymap, setBlockType, toggleMark } from 'prosemirror-commands';

const schema = new Schema({
  nodes: {
    doc: {
      content: 'block+',
    },
    paragraph: {
      content: 'text*',
      group: 'block',
      toDOM() {
        return ['p', 0];
      },
    },
    heading: {
      attrs: { level: { default: 1 } },
      content: 'text*',
      group: 'block',

      toDOM(node) {
        return ['h' + node.attrs.level, 0];
      },
    },
    text: {},
  },
  marks: {
    strong: {
      toDOM() {
        return ['b', 0];
      },
    },
    em: {
      toDOM() {
        return ['i', 0];
      },
    },
  },
});

const customKeymap = keymap({
  'Mod-b': toggleMark(schema.marks.strong),
  'Mod-i': toggleMark(schema.marks.em),
  'Ctrl-Alt-1': setBlockType(schema.nodes.heading, { level: 1 }),
});

const state = EditorState.create({
  schema,
  plugins: [customKeymap, keymap(baseKeymap)],
});

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

const boldBtn = document.getElementById('boldBtn');
boldBtn.addEventListener('click', () => {
  toggleMark(schema.marks.strong)(view.state, view.dispatch);
  view.focus();
});

キーボードショートカットだけではなくボタンに設定したイベントによって文字列を太字に変更することができるようになりました。太字以外にもイタリック表示なども同様の方法でボタンを追加することができます。

画像の挿入

これまではpタグからh1タグの変更や文字列の装飾(太字, イタリック)などの設定を行ってきましたがここでは画像の挿入方法を確認します。

どのように挿入処理を行うのか理解するために画像の挿入の前に文字列の挿入方法を確認しておきます。

文字列の挿入

ブラウザ上でのキーボードによる文字列の入力ではなくボタンをクリックすることで文字列を挿入する方法を確認します。index.htmlファイルでBoldボタンやHelloボタンに変更します。id属性の値もaddHelloに変更します。


<body>
  <div>
    <button id="addHello">Hello</button>
  </div>
  <div id="app"></div>
  <script type="module" src="/main.js"></script>
</body>

main.jsファイルではdocument.getElementByIdメソッドでbutton要素を取得して、イベントリスナーでclickイベントの処理を設定します。


const addHello = document.getElementById('addHello');
addHello.addEventListener('click', () => {
  const { state, dispatch } = view;
  const transaction = state.tr.insertText('Hello');
  dispatch(transaction);
});

行を追加したり文字を入力場合のエディターの状態の更新は必ずTransactionを通して行われます。上記のコードではstate.trでTransactionを開始し、insertTextを実行することでHelloという文字列を挿入する処理を実行しています。これだけではエディターの状態に反映は行われず、Transactionの処理はdispatchを利用して反映させます。transactionを実行してもdispatchを実行しなければエディターの状態は更新されません。

ブラウザ上に”初めてのProseMirror”の文字列を入力して、”ProseMirror”の文字列の前にカーソルを合わせます。その後”Hello”ボタンをクリックすると”ProseMirror”の文字列の前に”Hello”の文字列が挿入されます。

入力済みの文字列の間に文字列を追加
入力済みの文字列の間に文字列を追加

dispatchを実行すると通常は裏側でエディターの状態更新が行われますが、 EditorViewでdispatchTransactionを設定することでtransactionのdispatchを受け取り、状態を更新処理を行うことができます。


const view = new EditorView(document.getElementById('app'), {
  state,
  dispatchTransaction(transaction) {
    const newState = view.state.apply(transaction);
    view.updateState(newState);
  },
});

document.getElementById('addHello').addEventListener('click', () => {
  const { state, dispatch } = view;
  const transaction = state.tr.insertText('Hello');
  dispatch(transaction);
});

dispatchTransaction関数ではview.state.applyでtransactionを適用して新しい状態を作成しています。新しい状態はview.updateStateを利用して反映されます。view.updateState行をコメントすると反映は行われません。

ここでの動作確認を通して文字列を挿入した文字をエディタービューに反映させるためにはtransactionを実行後にtransactionをdispatchして、dispatchされたtransactionを受け取った後にエディターの状態に適用して新しい状態を反映させるという流れがあることがわかりました。

キーボードショートカットキーを実行するとdispatchTransactionの処理が実行されるだけではなく文字を1文字入力するだけでは状態の更新が行われるためdispatchTransactionが実行されます。
fukidashi

URLによる画像の挿入

画像挿入の処理を追加する前にスキーマのnodesにImageノードに関する設定を追加する必要があります。


const schema = new Schema({
  nodes: {
    doc: {
      content: 'block+',
    },
    paragraph: {
      content: 'inline*',
      group: 'block',
      toDOM() {
        return ['p', 0];
      },
    },
    heading: {
      attrs: { level: { default: 1 } },
      content: 'inline*',
      group: 'block',

      toDOM(node) {
        return ['h' + node.attrs.level, 0];
      },
    },
    image: {
      inline: true,
      attrs: {
        src: {},
        alt: { default: null },
      },
      group: 'inline',
      toDOM(node) {
        return ['img', node.attrs];
      },
    },
    text: {
      group: 'inline',
    },
  },
  marks: {
    strong: {
      toDOM() {
        return ['b', 0];
      },
    },
    em: {
      toDOM() {
        return ['i', 0];
      },
    },
  },
});

imageノードはインライン要素なのでinlineプロパティにtrueを設定します。paragraphやheadingはこれまでcontentにインライン要素のtextノードを設定していましたがtextノードだけではなくimageなどのインライン要素も含むことができるようにtextからinlineに変更しています。textノードではgroupにinlineを設定します。inlineへの設定変更がないと画像の挿入を行うことができません。

スキーマへのimageノードの設定が完了したらindex.htmlファイルで画像ボタンを設定します。


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div>
      <button id="addimage">Image</button>
    </div>
    <div id="app"></div>
    <script type="module" src="/main.js"></script>
  </body>
</html>

追加した画像ボタンに対してイベントリスナーでclickイベントを設定します。画像はURLで設定を行うため入力にはpromptを利用します。スキーマで定義したimageでノードの作成を行い、transactionではreplaceSelectionWithメソッドを実行します。実行したtransactionを反映させるためdispatchします。


document.getElementById('addimage').addEventListener('click', async () => {
  const url = await new Promise((resolve) => {
    const url = prompt('Enter the image URL:');
    resolve(url);
  });

  if (url) {
    const { state, dispatch } = view;
    const node = schema.nodes.image.create({ src: url });
    const transaction = state.tr.replaceSelectionWith(node);
    dispatch(transaction);
  }
});

ImageボタンをクリックするとpromptによりURLのダイアログ画面が表示されます。Viteでプロジェクトを作成した場合はpublicディレクトリにデフォルトでvite.svgが存在するのでそのファイルを指定します。publicディレクトリのvite.svg画像を利用しましたが外部のサーバのURLをしても表示されます。

imageボタンをクリックすると入力画面が表示
imageボタンをクリックすると入力画面が表示

URLを入力後、OKボタンをクリックすると挿入した画像が表示されます。pタグの中にimgタグで追加されていることがわかります。

画像が挿入できることを確認
画像が挿入できることを確認

スキーマを画像に対応できるように更新を行い、transactionとdispatchを利用することでエディタービュー上に画像の表示を行うことができました。

Dropによる画像の挿入

promptによって表示されるダイアログ画面に画像のURLを入力することでエディタービュー上に画像を表示することができました。次はエディタービュー上に画像をDropすることで画像が表示させる方法を確認します。

EditorViewはhandleClickhandleDropなどのイベントを設定することができます。Dropによる画像の挿入にはhandleDropを利用します。

画像をエディター上にDropするとイベントが発火するか確認するために以下のコードで動作確認を行います。


const view = new EditorView(document.getElementById('app'), {
  state,
  handleDrop: () => {
    console.log('handleDrop');
    return true;
  },
});

動作確認のためファイルをdropするエディタービューの領域を広げるため改行を行い、ファイルをDropします。Dropするとコンソールに”handleDrop”の文字列が表示されればhandleDropイベントは正常に動作しています。

エディター上にファイルをドロップ
エディター上にファイルをDrop

handleDropは引数からview, event, slice, movedを受け取ることができ、envetの中にDropしたファイルの状態が含まれています。

Dropしたファイルの状態を確認するためにeventを利用します。


const view = new EditorView(document.getElementById('app'), {
  state,
  handleDrop: (view, event) => {
    console.log(event.dataTransfer.files[0]);
    return true;
  },
});

ファイルをDropするとDropしたファイルの情報が表示されます。ファイルはpublicディレクトリにあるvite.svgファイルを利用しています。

Dropしたファイルの情報
Dropしたファイルの情報

Dropしたファイルが画像ファイルかどうかチェックを行い、FileReaderを利用して画像を読み込みimageノードを作成してtransactionとdispatchを利用してDropした画像の挿入処理を行っています。


const handleDrop = (view, event, slice, moved) => {
  const files = event.dataTransfer.files;

  if (files.length === 0) {
    return false;
  }

  const file = files[0];
  if (!file.type.startsWith('image/')) {
    return false;
  }

  const reader = new FileReader();
  reader.onload = (readerEvent) => {
    const img = new Image();
    img.src = readerEvent.target.result;
    img.onload = () => {
      const node = schema.nodes.image.create({
        src: img.src,
      });
      const transaction = view.state.tr.replaceSelectionWith(node);
      view.dispatch(transaction);
    };
  };
  reader.readAsDataURL(file);

  return true;
};

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

動作確認を行うためエディタービューにファイルをDropするとpromptでURLを入力した時とは異なり、src属性にはファイルのURLのパスではなく直接画像のデータが挿入されていることも確認できます。

Dropによる画像の挿入
Dropによる画像の挿入

EditorViewのイベントを利用することでDropによるファイルの挿入も行えることがわかりました。

Exampleを参考にMenuを作成

button要素を追加しイベントを設定することでボタンによりスタイルを変更する方法を確認しました。ProseMirrorのドキュメントに掲載されているExampleを利用して1つのボタンではなく複数のボタンを含むメニューを上部に設定していきます。ここまでの理解ができればExampleの例も簡単に実装することができます。

メニューの作成を通してPluginの作成方法も理解できます。

MenuView Classの作成

MenuView ClassではボタンをWrapするdiv要素を作成(createElement)してそのdiv要素の中にbutton要素を追加(appendChild)してメニューとします。追加するbutton要素はMenuViewの引数の配列itemsから受け取ります。配列items要素にはcommand, domプロパティを持つオブジェクトを設定します。受け取った引数のitemsをforEachメソッドで展開して各button要素にはイベントリスナーでmousedownイベントを設定します。イベントではクリックすると各button要素に対応したcommand(toggleMarkなど)が実行できるように設定を行っていきます。updateメソッドでは各commandが現在のEditorの状態に対して実行可能かチェックを行っています。エディターの状態に変更が行われ度に実行されます。destroyメソッドは削除される際に実行されます。


class MenuView {
  constructor(items, editorView) {
    this.items = items;
    this.editorView = editorView;
    this.dom = document.createElement('div');
    this.dom.className = 'menubar';

    items.forEach(({ dom }) => this.dom.appendChild(dom));

    this.update();

    this.dom.addEventListener('mousedown', (e) => {
      e.preventDefault();
      editorView.focus();
      items.forEach(({ command, dom }) => {
        if (dom.contains(e.target))
          command(editorView.state, editorView.dispatch);
      });
    });
  }

  update() {
    this.items.forEach(({ command, dom }) => {
      const active = command(this.editorView.state, null);
      console.log('active', active);
      dom.style.display = active ? '' : 'none';
    });
  }

  destroy() {
    this.dom.remove();
  }
}

メニューの設定

MenuView Classに引数に渡すitemsをcommandListsとして設定します。domプロパティはMenuView Classの中でappendChildとしてメニューのdiv要素に追加されるのでaddButton関数を利用してbutton要素として設定できるようにしています。


const addButton = (text) => {
  const button = document.createElement('button');
  button.textContent = text;
  return button;
};

const commadLists = [
  { command: toggleMark(schema.marks.strong), dom: addButton('B') },
  { command: toggleMark(schema.marks.em), dom: addButton('I') },
];

Pluginの作成

作成したMenu ClassをPluginとして設定します。Pluginについてはドキュメントに記載されているので参考にしています。作成するmenuPluginの引数でitemsを受け取ります。Pluginではview関数の引数でeditorViewを受け取ることができます。editorView.domは<div id=”app”></div>の中に作成される要素です。parentNodeメソッドでその親要素である<div id=”app”></div>にmenuViewのdomプロパティに含まれる要素であるメニューをwrapするdiv要素を追加しています。作成したPluginはEditorState.createで設定を行います。


import { Schema } from 'prosemirror-model';
import { EditorState, Plugin } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { keymap } from 'prosemirror-keymap';
import { baseKeymap, setBlockType, toggleMark } from 'prosemirror-commands';
//略
const menuPlugin = (items) => {
  return new Plugin({
    view(editorView) {
      const menuView = new MenuView(items, editorView);
      editorView.dom.parentNode.insertBefore(menuView.dom, editorView.dom);
      return menuView;
    },
  });
};

const state = EditorState.create({
  schema,
  plugins: [customKeymap, keymap(baseKeymap), menuPlugin(commadLists)],
});

ブラウザで設定したメニューの要素を確認すると説明した通り<div id=”app”></div>要素の中にメニューをwrapしたdiv要素が追加されていることが確認できます。

追加したメニューの確認
追加したメニューの確認

文字列を入力してBボタン、Iボタンをクリックすると太字でイタリックにスタイルを変更することができます。

追加メニューの動作確認
追加メニューの動作確認

このようにエディタービューの上部にメニューを作成することができました。メニューには何もスタイルが設定されていないので各自で設定する必要があります。リンクの設定などRich Text Editorとして追加を行わなければなりませんがここまでの設定でProseMIrrorでRich Text Editorを作成するためにはどのような設定が必要になるのかは理解できたのではないでしょうか。Tiptapなどを利用していないのであればぜひこの後にTiptapなのでライブラリにもチャレンジしてみてください。