今回からしばらく、Tiptapによるエディタ開発の話をする予定です。

技術構成の回でお話ししたように、Tiptapの内部ではProseMirrorが使われています。

ProseMirrorの全貌を追うことは大変な作業ですが、Tiptapを使っていても見える部分だけに絞って、ProseMirrorの構成要素を大まかに知っておくと、Tiptapエディタのカスタマイズがスムーズに進められます。

ProseMirrorの文書構造

DocumentとNode

ProseMirrorのエディタ内部では、テキストや段落などのコンテンツはすべてDocumentと呼ばれる木構造のデータとして表現・管理されています。

エディタに設定したコンテンツ
<p>
  Hello
  <strong>world</strong>
</p>
ProseMirror内部で持つ文書データの構造
Document
 └─ Node(type="paragraph")
     ├─ Node(type="text") "Hello "
     └─ Node(type="text", marks=[bold]) "world"

Document内の各要素はNodeと呼ばれ、概ねHTML要素が1つのNodeに対応しているように見えます。
HTMLタグの中のただのテキストも、type="text"Nodeとして扱われていることに注目しましょう。

属性としてのMark

重要なのは、strong要素に対応するNodeはなく、marks=[bold]というテキストノードの属性として表現されているところです。
ProseMirrorでは、テキストに適用される装飾をMarkと呼びます。

現代のHTMLでは、装飾のためだけにHTML要素を使うことはあまり推奨されないものです。
テキストを太字に装飾したい場合は、strong要素を使うよりも、CSSでfont-style: bold;と指定する方が良いでしょう。

「HTMLは文書構造 / CSSは装飾」という役割分担と同様に、ProseMirrorでも「文書構造の構成要素はNode / 装飾はMark」というように分けられています。
そして、HTML要素のstyle属性でCSSを付与できるのと同じように、MarkNodeに付与できる属性として扱われるのです。

Documentは特別なNode

ちなみに、Document自体もtype="doc"Nodeとして扱われます。
docノードはルート専用の特別なNodeで、文書ツリーのすべてのNodeを包むように必ず存在する必要があります。

Document全体をJSONで表したもの
{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "Hello " },
        { "type": "text", "marks": [{ "type": "bold" }], "text": "world" }
      ]
    }
  ]
}

スキーマによる文書構造の制限

ProseMirrorには、コンテンツの文書構造を制限する、スキーマという仕組みが備わっています。
スキーマは、「各ノードごとの文法ルール」を束ねたオブジェクトです。

NodeSpec:各ノードごとのルール

たとえば、段落を表すparagraphノードに対して、

  • paragraphはブロック要素である
  • paragraphの中にはインライン要素を含めることができる

というルールを定めることで、「paragraphのネストはできない」という文法が表現できます。

このように、ノードに対して、

  • ノードのカテゴリ(ブロック要素なのかインライン要素なのか)
  • 許可する子ノードの種類
  • 許可するMarkの種類(※textノードの場合)

などを定義しておくと、ノード自体とその内部(子ノード)に対するルールを制御できます。

このようなノードごとの設定をNodeSpecといい、次のような項目(フィールド)を持ちます。

フィールド説明
groupどのカテゴリに属するか(例:block / inline
contentどんな子ノードを持てるか
marksどんなマーク(装飾)を許可するか
parseDOM / toDOMHTMLとの相互変換ルール
attrs属性の定義

Content Expressions:文法ルールを表す構文

NodeSpeccontentフィールドには、許可する子ノードの配置をContent Expressionsという構文に従う文字列で指定します。

たとえば、paragraphノードのNodeSpeccontentフィールドにtext*という文字列を指定すると、「paragraphの子として0個以上のtextノードが許可される」という制約が与えられます。
単なるテキストだけでなく、インラインコードなども含めたい場合は、inline*と書くことになります。

また、「0個以上」ということで*がついていましたが、子ノードの存在を必須にしたい場合は、「1つ以上」を表す+を用いて、inline+とします。

DocumentのNodeSpec:文書全体のルール

ここで思い出してほしいのが、Documentもノードの一種(docノード)であるという事実です。
つまり、docノードにもNodeSpecが用意されます。そして、そのcontentフィールドの値が、文書全体に対する文法ルールとなるのです。

たとえば、heading block*という文字列をcontentに指定することで、「文書の最初にはまず見出しを置く」というルールを表現できます。

Tiptapで文書全体のルールを設定したいときも、Documentcontentを書き換える形で行うことになります。
詳しい実装は次回解説しますが、このようなTiptapでのカスタマイズはすべてExtensionで表現することになるので、Extensionについて軽く紹介しておきたいと思います。

TiptapではExtensionがカスタマイズの単位

Tiptapでは、独自のNodeMark、ショートカットや履歴管理などエディタの機能を追加する仕組みをExtensionとして提供しています。

本来はProseMirrorのさまざまな概念を区別して組み合わせなければならないカスタマイズも、TiptapではExtensionという一貫した枠組みで実装できるようになっています。
エディタの動作を制御するプラグインも、NodeMarkの定義も、スキーマ定義を持つDocumentも、TiptapではすべてExtensionとして実装され、初期化時に設定することで使えるようになるのです。

Tiptapでは、Extensionが内部的にProseMirrorのNodeSpecなどを自動生成してくれることで、ProseMirrorの概念に深く触れずにノードや機能を追加することができます。

Next Step

Tiptapでタイトルを必須化する

Deep Dive