「訳」 ProseMirror 日本語ガイド

✍🏼 作成日 2019年09月06日    💡 更新日 2021年11月15日
❗️ 注意:この記事が作成されてから既に 日が経過しています。情報の鮮度にご注意ください
🖥  説明:本文で言及されているドキュメントやマニュアルのAPI説明は、すべてhttps://prosemirror.xheldon.com/docs/ref/で確認できます。

文中でhttps://prosemirror.xheldon.comドメインを指す内容は、すべてhttps://prosemirror.netに置き換えることで英語原文を取得できます

翻訳に関する説明:

  1. 作業でProseMirrorを使用する必要がありますが、市場には翻訳が完璧なドキュメントが見つかりませんでした(一部の翻訳は機械翻訳のように読めます)。この機会に、このライブラリのコンセプト説明ドキュメントを翻訳することにしました。
  2. これまでの翻訳経験から、曖昧さを避けるために、一部の专有名词不翻译を使用することが最善の選択であると考えています。
  3. 原文に忠実であることを心がけていますが、直接翻訳すると文脈が不自然になる場合があるため、必要に応じて主語などの追加情報を加えたり、意訳したりしています。読みにくい部分がある場合は原文を確認してください。
  4. 理解できない部分については、Prosemirrorのフォーラムで作者に質問しました。説明リンクを掲載しています。
  5. このガイドをいきなり読んでも理解できないかもしれません。まずざっと目を通した後、このリポジトリで基本的な機能(heading(ノードタイプ)や太字(マーク)タイプ)がどのように実装されているかを確認し、実装してみてから、このガイドを再度読むと理解が深まります。
  6. 私は中国語入力法で英語の句読点を使用するのが好きです。
  7. 一部の場所でProseMirrorをProsemirrorと誤記している場合がありますが、このガイドには影響しません。
  8. 簡単なデモを作成し、基本的なサンプルを含めて実験しやすいようにしました。このリポジトリにあります。fork/starをお待ちしています。
  9. 中国語と英語の間にスペースを入れ、コンマの後にスペースを入れるのは一般的な操作です。
  10. 私の技術力、翻訳レベル、認識には限界があります。不適切な点があれば指摘してください。ありがとうございます!

翻訳者が理解するコンセプト説明

  1. Document: Prosemirrorが扱うドキュメント全体。通常、editor.view.state.docがその参照を保持します。
  2. Schema: Prosemirrorの骨格オブジェクト。エディターのさまざまなルールを定義し、ドキュメントを制約します。場合によってはこれらのルールに合わせて手動で処理する必要がありますが、ほとんどの場合Prosemirrorが自動的に処理します。
  3. State: Prosemirrorのデータ構造オブジェクト。Reactのstateに相当します。viewのstateとpluginのローカルstateがあります。上記のschemaはその上で定義されています: state.schema。
  4. View: Prosemirrorのビューオブジェクト。ビューを更新するメソッドがあり、stateはそのプロパティの1つです: view.state。
  5. Transform: ドキュメントの変更を保持するコンテナオブジェクトと理解できます。また、変更を修正するメソッドもあります。transactionはそのサブクラスで、エディター全体のstate変更を対象とします。
  6. Selection: 選択範囲オブジェクト。何も選択していない場合はカーソルを表します。位置に関連する複数のプロパティとメソッドがあります。
  7. Range: 複数のノードオブジェクトのコンテナ。通常、選択範囲内に複数のタイプのノードとMarkが含まれる場合に処理します。
  8. Slice: 選択範囲が中途半端な場合にschema構造に適合しない問題を処理するためのオブジェクトです。
  9. Node: Prosemirrorの基本要素。schemaを使用してさまざまなタイプのノードを定義できます。少なくともdoc(ルートノード)とtext(テキストノード)の2種類のノードが含まれます。
  10. NodeType: Prosemirrorのノードタイプ。一般的に新しいノードを作成するために使用されます。特定のタイプのノードの属性を定義します。
  11. XXXSpec: XXXを定義する際の設定オブジェクト。NodeSpec、MarkSpecなど。
  12. Mark: ProsemirrorはインラインテキストをDOMのようなツリー構造ではなくフラット構造として扱い、カウントと操作を容易にします。したがって、Markはインラインノードの属性(font-size、boldなど)を表し、カスタマイズが可能です。
  13. MarkType: ノードタイプと同様に、Markの属性を定義します。その上のメソッドを使用してmarkを作成できます。
  14. DOMOutputSpec: schema内のtoDOMで指定された戻り値です。公式説明
  15. ResolvedPos: Prosemirrorが位置情報を解析して返すオブジェクト(以下の位置カウントセクションを参照)。位置に関連する情報が含まれます。
  16. Plugin: 通常、Pluginを使用してクリック/貼り付け/元に戻すなどの動作を実装します。Pluginはノードを直接定義することもできます。
  17. Decoration: 通常、ドキュメントの状態とは無関係なビューを生成するために使用されます。ドキュメント構造に影響を与えずに特殊効果を実現できます。

ProseMirror 日本語ガイド

このガイドでは、このライブラリで使用されるさまざまな概念と、それらがどのように関連しているかを説明します。システム全体のイメージをつかむために、ドキュメントの順番に読むことをお勧めします。あるいは(もし我慢できずにざっと理解したい場合)、少なくともViewコンポーネントのセクションまで読んでください。

イントロダクション

ProseMirrorは、リッチテキストエディタを構築するための包括的なツールセットと概念を提供します。そのユーザーインターフェースは所见即所得の概念に影響を受けていますが、スタイル編集の泥沼にはまらないように注意しています。

ProseMirrorの基本的な考え方は、あなたとあなたのコードがドキュメントとその変更を完全に制御することです。ここでのドキュメントは、HTMLのごちゃごちゃしたコードの塊ではなく、あなたが明示的に許可した要素とそれらの間の指定された関係のみを含むカスタムデータ構造です(つまり、どの要素が出現できるか、要素間の関係はすべてあなたの管理下にあるということです——訳者注)。すべてのドキュメント更新操作は単一のポイントから発生するため、更新を処理しやすくなっています。

ProseMirrorのコアモジュールはそのまま使えるものではありません。このライブラリを開発する際、私たちはモジュール性とカスタマイズ性を簡潔性よりも優先しました。もちろん、将来的にはProseMirrorベースのすぐに使えるエディタが開発されることを期待しています。例えるなら、ProseMirrorは組み立てが必要なレゴブロックのようなもので、開封してすぐ使えるマッチ箱のようなものではありません。

ProseMirrorには4つの必須モジュールがあり、あらゆる操作にはこれらが必要です。さらに、ProseMirrorコアチームがメンテナンスする多くの拡張モジュールがあり、これら(拡張モジュール)は多くの便利な機能を提供するサードパーティモジュールと同様に、同じ機能を実装した他のモジュールで置き換えることができます。

前述の4つの必須モジュールは次のとおりです:

  1. prosemirror-model はエディタのDocument Modelを定義し、エディタの内容を記述します。
  2. prosemirror-state は、エディタの完全な状態を記述する単一のデータ構造を提供します。これにはエディタの選択操作や、現在のstateから次のstateへの処理を行うtransactionシステムが含まれます。
  3. prosemirror-view は、与えられたstateをエディタ内で編集可能な要素として表示し、ユーザーインタラクションを処理します。
  4. prosemirror-transform は、ドキュメントを変更する機能を含み、その変更はやり直しや取り消しが可能です。これはprosemirror-stateライブラリのtransaction機能の基礎であり、操作履歴の取り消しや共同編集を可能にします。

これ以外にも、基本的な編集コマンドキーバインディング操作履歴とロールバックマクロコマンド共同編集、そしてシンプルなドキュメントSchemaなどのモジュールがあります。その他のモジュールはGithubのProseMirror組織で見つけることができます。

ProseMirrorはブラウザで直接読み込めるスクリプトではありません。つまり、使用するにはバンドルツールが必要です。バンドルツールとは、スクリプトで宣言された依存関係を自動的に見つけ出し、それらを単一のスクリプトファイルに結合して、ブラウザで簡単に読み込めるようにするものです。Webバンドリングについてさらに学びたい場合は、例えばここを参照してください。

最初のエディタ

以下のコードは、最もシンプルなエディタを作成するためにレゴブロックのように積み上げられています:

1
2
3
4
5
6
import { schema } from 'prosemirror-schema-basic';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';

let state = EditorState.create({ schema });
let view = new EditorView(document.body, { state });

Prosemirrorでは、ドキュメントが従うべきスキーマ(どの要素が含まれるべきか、含まれないべきか、および要素間の関係を規定するもの)を手動で指定する必要があります。この目的を達成するために、上記のコードが最初に行うことは、基本的なスキーマをインポートすることです(通常、スキーマは自分で作成しますが、ここでは著者が基本的な要素を含む既存のスキーマを例として使用しています——訳者注)。

その後、この基本スキーマを使用してstateが作成されます。このstateは、スキーマの制約に従った空のドキュメントと、そのドキュメントの先頭にデフォルトの選択範囲(この選択範囲は空であるため、ここではカーソルを指します)を生成します。最終的に、このstateはviewを生成し、document.bodyに追加されます。上記のstateのドキュメントは、編集可能なDOMノード(contenteditable属性を持つノード——訳者注)と、ユーザーの入力に反応するstate transactionとしてレンダリングされます。

(残念ながら)現時点では、このエディタはまだ使用できません。例えば、先ほどのエディタでEnterキーを押しても何も起こりません。これは、前述した4つのコアモジュールがEnter入力後に何をすべきかを知らないためです。後ほど、さまざまな入力動作にどのように応答するかを設定します。

Transactions

ユーザーが入力するとき、またはより一般的に、ユーザーがページのviewと対話するとき、Prosemirrorは「state transactions」を生成します。これは、ユーザーが入力するたびに、Prosemirrorが単にドキュメントの内容を変更するだけでなく、背後でstateも更新することを意味します。つまり、各変更にはtransactionが作成され、stateに適用された変更が記述されます。これらの変更は新しいstateを作成するために使用でき、その後、この新しいstateがviewを更新するために使用されます。

デフォルトでは、上記の変更はフレームワークによって処理され、ユーザーが気にする必要はありません。ただし、pluginを記述したり、viewをカスタマイズしたりすることで、この変更プロセスにフックを追加できます。例えば、以下のコードはdispatchTransaction propを追加し、各transactionが作成されるときに呼び出されます:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 忽略 import 部分
let state = EditorState.create({ schema });
let view = new EditorView(document.body, {
state,
dispatchTransaction(transaction) {
console.log(
'Document size went from',
transaction.before.content.size,
'to',
transaction.doc.content.size
);
let newState = view.state.apply(transaction);
view.updateState(newState);
},
});

毎回のstate更新は最終的にupdateStateメソッドを実行する必要があり、通常、transactionをdispatchするたびに編集状態の更新がトリガーされます。

Plugins

Pluginsは、編集動作と編集状態をさまざまな方法で拡張するために使用されます。一部のプラグインは比較的単純です。例えばkeymapプラグインは、キーボード入力のactionsをバインドするために使用されます。また、historyプラグインのように、transactionを監視し、それらを逆順に保存してユーザーがtransactionを元に戻したい場合にundo/redo機能を実現する、より複雑なプラグインもあります。

まず、以下の2つのプラグインを追加してundo/redo機能を有効にしましょう:

1
2
3
4
5
6
7
8
9
// 忽略重复的导入
import { undo, redo, history } from 'prosemirror-history';
import { keymap } from 'prosemirror-keymap';

let state = EditorState.create({
schema,
plugins: [history(), keymap({ 'Mod-z': undo, 'Mod-y': redo })],
});
let view = new EditorView(document.body, { state });

Pluginsはstateの作成時に登録されます(stateのtransactionにアクセスする権限が必要なため)。このundo/redo可能なstateに対してviewを作成した後、Ctrl+Z(またはMacではCmd+Z)を押すことで前の操作を元に戻すことができるようになります。

Commands

上記の例で、関連するキーボードキーにバインドされた特別な関数はcommandsと呼ばれます。ほとんどの編集動作はcommandsとして記述されるため、特定のキーにバインドしたり、編集メニューから呼び出したり、ユーザーが操作できるように公開したりできます。

prosemirror-commandsパッケージは、enterキーやdeleteキーの動作をエディタで期待通りにマッピングするなど、多くの基本的な編集commandsを提供します。

1
2
3
4
5
6
7
8
9
10
11
12
// 忽略重复的导入
import { baseKeymap } from 'prosemirror-commands';

let state = EditorState.create({
schema,
plugins: [
history(),
keymap({ 'Mod-z': undo, 'Mod-y': redo }),
keymap(baseKeymap),
],
});
let view = new EditorView(document.body, { state });

これで、基本的に動作するエディタが完成するはずです。

編集操作を容易にするメニューを追加したり、スキーマで許可されたキーバインドを追加したりする場合は、prosemirror-example-setupパッケージを参照すると良いでしょう。このパッケージは、基本的なエディタを実装するための一連の設定済みプラグインを提供しますが、パッケージ名が示すように、これはAPIの使用例を示すためのものであり、本番環境で使用するためのパッケージではありません。実際の開発環境では、正確に望む効果を実現するために、これらの内容の一部を独自のコードで置き換える必要があるかもしれません。

Content

stateのdocumentオブジェクトはdocプロパティに格納されており、これは読み取り専用のデータ構造で、一連の異なる階層のノードで表現されます。これらのノードの階層構造は、ブラウザのDOMノードとやや似ています。単純なdocumentは「doc」ノードを持ち、それが2つの「paragraph」ノードを含み、各「paragraph」ノードはさらに1つの「text」ノードを含むかもしれません。guideでdocumentデータ構造についてさらに詳しく読むことができます。

stateを初期化する際、初期documentを渡すことができます。この場合、schemaフィールドはオプションです。なぜならschemaはdocumentから取得できるからです。

以下の例では、DOMフォーマットメカニズムを使用して、IDが「content」のDOM要素をフォーマットし、stateを初期化しています。このstateが使用するschema情報は、DOMノードをフォーマットして対応する要素にマッピングすることで得られます(つまり、DOMノードに含まれる要素がフォーマット後にschemaの形式に対応付けられ、stateで使用されるため、schema情報を手動で指定する必要はありません——訳者注)。

1
2
3
4
5
6
7
8
import { DOMParser } from 'prosemirror-model';
import { EditorState } from 'prosemirror-state';
import { schema } from 'prosemirror-schema-basic';

let content = document.getElementById('content');
let state = EditorState.create({
doc: DOMParser.fromSchema(schema).parse(content),
});

Documents

Prosemirrorはdocumentコンテンツを表現するための独自のデータ構造を定義しています。documentはエディタを構築する核心要素であるため、documentがどのように機能するかを理解することが重要です。

Structure

Prosemirrorのdocumentはnodeタイプであり、fragmentオブジェクトを含みます。fragmentオブジェクトは0個以上の子nodeを含みます。

これはブラウザのDOM構造によく似ています。なぜならProsemirrorもDOMと同様に再帰的なツリー構造だからです。ただし、Prosemirrorはインライン要素の保存方法においてDOMとは少し異なります。

HTMLでは、paragraphとそれに含まれるマークアップはツリーのように表現されます。例えば以下のHTML構造:

1
2
3
4
5
6
<p>
This is{' '}
<strong>
strong text with <em>emphasis</em>
</strong>
</p>

dom structure

しかしProsemirrorでは、インライン要素はフラットなモデルで表現され、ノードのマークは対応するノードにメタデータとして付加されます:

prosemirror-document-structure

このデータ構造は明らかに、この種のテキストが持つべき姿に合致しています。これにより、段落内の位置をツリーノードのパスではなく文字オフセットで表現でき、コンテンツの分割やスタイル変更などの操作を、不格好なツリー操作ではなく簡単に行うことができます。

また、これは各documentが一意のデータ構造表現を持つことも意味します。テキストノード内で隣接し同じmarksを持つものは結合され、空のテキストノードは許可されません。marksの順序はschemaで指定されます。

したがって、Prosemirror documentはblock nodesのツリーであり、そのほとんどのリーフノードは_textblock_タイプです。これはテキストを含むblock nodesです。また、水平区切り線hr要素やvideo要素のように、内容が空の単純なリーフノードも持つことができます。

Nodeオブジェクトには、ドキュメント内での役割を示す一連のプロパティがあります:

  • isBlockisInlineは、そのnodeがblockタイプ(divのような)かinlineタイプ(spanのような)かを示します。
  • inlineContentがtrueの場合、そのnodeはインライン要素のみをcontentとして受け入れます(このノードを判断して、次にインラインノードを追加するかどうかを決定できます——訳者注)
  • isTextBlockがtrueの場合、そのnodeはインラインコンテンツを含むblock nodesです。
  • isLeafがtrueの場合、そのnodeはあらゆるコンテンツを含むことを許可しません。

したがって、典型的な「paragraph」ノードはtextblockタイプのノードであり、blockquote(引用要素)は他のブロック要素で構成される可能性のあるブロック要素です。テキストノード、改行、インライン画像はすべてインラインリーフノードであり、水平区切り線(hr要素)ノードは典型的なブロックリーフノードです(リーフノードは子ノードを持たないことを意味し、前述のようにインラインまたはブロックのいずれかになります——訳者注)。

Schemaは、「どの要素がどこに許可されるか」といった制約条件をさらに指定することを可能にします。たとえば、ノードがブロックコンテンツを許可している場合でも、すべてのブロックノードをコンテンツとして許可するわけではありません(スキーマで手動で例外を指定できます——訳者注)。

同一性と永続性

DOMツリーとProseMirrorドキュメントのもう一つの違いは、ノードオブジェクトの表現方法です。DOMでは、ノードは同一性を持つ可変(mutable)オブジェクトです(可変オブジェクトが何かわからない場合は検索してください)。つまり、ノードはその親ノードの下にしか存在できません(他の場所に現れた場合、元の場所からは消えます。同一性があるため、唯一だからです——訳者注)。このノードが更新されると、それは突然変異(mutated)します(ノードの更新は元のノード上で行われ、変更前後で同じオブジェクトであることを意味します——訳者注)。

一方、ProseMirrorでは異なります。ノードは単なる値(DOMの可変とは異なり、値は不変(unmutable)です)であり、ノードを表現することは数字の3を表現するのと似ています。3は異なるデータ構造に同時に存在でき、現在のデータ構造に縛られません。それに1を加えると、新しい値4が得られ、元の3は何も変更されません。

これがProseMirrorドキュメントの仕組みです。その値は変更されず、新しいドキュメントを計算するためのプリミティブ値として扱われます。これらのドキュメントのノードは、どのデータ構造に存在するかを認識しません。なぜなら、それらは複数の構造に存在でき、1つの構造内で繰り返し現れることもあるからです。それらは値であり、状態を持つオブジェクトではありません。

つまり、ドキュメントを更新するたびに新しいドキュメントが得られます。この新しいドキュメントは、更新で変更されなかったすべての子ノードの値を共有するため、新しいドキュメントの作成は低コストです。

この仕組みには多くの利点があります。状態が更新されるとき、エディタは常に利用可能です。なぜなら、新しい状態は新しいドキュメントを表すからです(更新が完了していない場合、状態は存在せず、ドキュメントも存在しないため、エディタは以前の状態とドキュメントのままです——訳者注)。新旧の状態は瞬時に切り替えられます(中間状態なしで)。この状態切り替えは、単純な数学的推論の方法で行うことができます。一方、背後で値が絶えず変化している(DOMノードのように突然変異する——訳者注)場合、この推論は非常に困難です。ProseMirrorのこの仕組みにより、共同編集が可能になり、以前に画面に描画されたドキュメントと現在のドキュメントを比較するアルゴリズムによって、非常に効率的にDOMをupdateできます。

ノードは通常のJavaScriptオブジェクトとして表現され、そのプロパティを明示的にfreezing(突然変異を防ぐ)するとパフォーマンスに大きな影響を与えるため、実際にはProseMirrorのドキュメントは非突然変異の仕組みで動作しますが、手動で変更することもできます。ただし、ProseMirrorはこれをサポートしていません。これらのデータ構造を強制的に突然変異させると、エディタがクラッシュする可能性があります。なぜなら、これらのデータ構造は常に複数の場所で共有されているからです(1箇所を変更すると、他の知らない場所にも影響を与えます——訳者注)。したがって、十分に注意してください!また、この原則は、ノードオブジェクトに格納されている配列やオブジェクトにも適用されます。たとえば、ノード属性オブジェクトやフラグメント上の子ノードなどです。

データ構造

ドキュメントのデータ構造は次のようになります:

prosemirror-data-structure

各ノードはNodeクラスのインスタンスです。これらはtype属性で分類され、type属性からノードの名前、使用可能な属性などの情報を知ることができます。ノードタイプ(およびマークタイプ)は各スキーマごとに一度だけ作成され、自身がどのスキーマに属しているかを認識しています。

ノードのコンテンツはFragmentインスタンスを指すフィールドに格納され、その内容はノードの配列です。コンテンツを持たない、またはコンテンツを許可しないノードであっても同様で、これらのノードは共有のempty fragmentで置き換えられます。

一部のノードタイプは属性を持つことができ、各ノードに(コンテンツとは別の)追加の値として保存されます。例えば、画像ノードは属性を使用してaltテキストやURL情報を格納する場合があります。

さらに、インラインノードにはいくつかのアクティブなマーク(強調やリンクなどの要素を指すMarkインスタンス)が含まれます。

ドキュメント全体も1つのノードです。ドキュメントのコンテンツは最上位ノードの子ノードとして存在します。通常、これらの最上位ノードの子ノードは一連のブロックノードであり、その中にはインラインコンテンツを含むテキストブロックを持つものもあります。ただし、最上位ノードが単一のテキストブロックである場合もあり、その場合ドキュメント全体がインラインコンテンツのみで構成されることになります。

どのノードがどの位置に現れるかは、ドキュメントのschemaによって決定されます。プログラム的に(エディタへの直接入力ではなく)ノードを作成するには、スキーマを介する必要があります。例えば以下のnodetextメソッドを使用します。

1
2
3
4
5
6
7
8
import { schema } from 'prosemirror-schema-basic';

// null 参数的位置是用来在必要的情况下指定属性的
let doc = schema.node('doc', null, [
schema.node('paragraph', null, [schema.text('One.')]),
schema.node('horizontal_rule'),
schema.node('paragraph', null, [schema.text('Two!')]),
]);

インデックス化

Prosemirrorノードは2種類のインデックス化をサポートしています。オフセットを使用して各ノードを区別するツリー構造として扱う方法と、一連のトークンを持つフラットな構造(トークンはカウント単位と理解できます)として扱う方法です。

最初のインデックスでは、DOMと同様に個々のノードと対話でき、child methodchildCountを使用して子ノードに直接アクセスしたり、descendantsnodesBetweenを使ってドキュメントを走査する再帰関数を作成したりできます(すべてのノードを走査したい場合)。

2番目のインデックスは、ドキュメント内の特定の位置を指定する際に有用です。これはドキュメント内の任意の位置を整数で表すことができ、この整数はトークンの順序を示します。これらのトークンオブジェクトは実際にはメモリ上に存在しませんが、ドキュメントのツリー構造と各ノードが自身のサイズを認識しているため、位置によるアクセスが効率的に行えます。

  • ドキュメントの開始位置(すべてのコンテンツの前)は0です。
  • リーフノードでないノード(コンテンツを含むことができるノード)の出入りは1トークンとしてカウントされます。したがって、ドキュメントが段落(pタグ)で始まる場合、段落の開始位置は1です(<p>の後の位置)。
  • テキストノードの各文字は1トークンとしてカウントされます。したがって、ドキュメントの最初の段落に「hi」という単語が含まれている場合、position 2は「h」の後、position 3は「i」の後、position 4は段落全体の後(</p>の後)になります。
  • コンテンツを許可しないリーフノード(画像ノードなど)は1トークンとしてカウントされます。

したがって、次のようなHTMLで表されるドキュメントがある場合:

1
2
<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>

トークンの順序と位置は次のようになります:

prosemirror-indexing

各ノードにはnodeSize属性があり、ノード全体のサイズを表します。また、.content.sizeでノードのコンテンツサイズを取得できます。ただし、documentの最外層ノード(DOMでcontenteditable属性が設定されているノード、つまりdocumentのルートノード)の場合、開始トークンと終了トークンはdocumentの一部とは見なされません(documentの外にキャレットを置くことはできないため)。したがって、documentのサイズはdoc.content.sizeであり、doc.nodeSizeではありません(documentの開始/終了タグはdocumentの一部とは見なされませんが、依然としてカウントされます。後者は常に前者より2大きくなります)。

これらの位置を手動で計算するにはかなりの計算作業が必要です。そのため、Node.resolveを呼び出すことで、positionに関するより詳細なデータ構造を取得できます。このデータ構造からは、現在のpositionの親ノード、親ノード内でのオフセット、祖先ノードなどの情報が得られます。

子ノードのindex(childCountなど)、document範囲のposition、ノードのオフセット(再帰関数で現在処理中のノード位置を表す場合など)の違いを明確に区別することが非常に重要です。

スライス

ユーザーのコピー&ペーストやドラッグ操作などでは、「documentのスライス」(文書断片)という概念が関わってきます。例えば、2つのposition間のコンテンツがスライスです。このスライスは完全なノードやフラグメントとは異なり、「open」状態(スライスに含まれるタグが閉じられていない状態、例えば<p>123</p><p>456</p>ではスライスが23</p><p>45になる場合)である可能性があります。

例えば、段落の中間から別の段落の中間までを選択した場合、そのスライスには2つの段落が含まれ、最初の段落は開始位置でopen、2番目の段落は終了位置でopenになります。一方、インターフェースを通じて段落ノードを選択した場合(ビューとの相互作用ではなく)、closeされたノードを選択したことになります。スライスを通常のノードコンテンツのように扱うと、そのコンテンツはスキーマの制約に合致しない可能性があります。なぜなら、必要なノード(スライスコンテンツを完全なノードにするためのタグ、例えば上記の例では開始部分の<p>と終了部分の</p>)がスライスの外側にあるためです。

Sliceデータ構造は、このようなデータを表現するために使用されます。これは、両側のopen depth(ルートノードからの階層深度)情報を含むfragmentを保持しています。ノード上のslice methodを使用して、documentから「スライス」を「切り取る」ことができます。

1
2
3
4
5
6
7
//假设文档有两个 p 标签, 第一个 p 标签包含 a, 另一个 p 标签包含 b, 即:
// <p>a</p><p>b</p>
let slice1 = doc.slice(0, 3); // The first paragraph
console.log(slice1.openStart, slice1.openEnd); // → 0 0
let slice2 = doc.slice(1, 5); // From start of first paragraph
// to end of second
console.log(slice2.openStart, slice2.openEnd); // → 1 1

変更

ノードとフラグメントは永続的数据構造(不変)であるため、直接変更してはいけません。documentを操作する必要がある場合、それは常に不変のままであるべきです(操作後に新しいdocumentが生成され、古いdocumentは変更されません)。

ほとんどの場合、ノードを直接変更せずにtransformationsを使用してdocumentを更新します。これは変更記録を残すのにも便利で、編集状態の一部としてのdocumentには変更記録が必要です。

もし手動でdocumentを更新する必要がある場合、ProsemirrorはNodeFragmentに、新しいバージョンのdocumentを作成するための便利なヘルパー関数を提供しています。Node.replaceメソッドを頻繁に使用するかもしれませんが、これは指定されたdocumentの範囲内のコンテンツを新しいcontentを含むsliceで置き換えます。nodeを浅く更新したい場合は、copyメソッドを使用できます。このメソッドは同じnodeの新しいインスタンスを作成しますが、新しいcontentを指定することが可能です。Fragmentsにも、replaceChildappendなど、documentを更新するためのメソッドがあります。

スキーマ

各Prosemirrorのdocumentには、関連するschemaがあります。このスキーマは、document内に存在するnodesのタイプと、それらのnodesのネスト関係を記述します。例えば、スキーマは、最上位のnodeが1つ以上のblocksを含むことができると規定し、段落paragraph nodesは任意の数のinline nodesを含むことができ、これらのinline nodesは任意の数のmarksを含むことができるとします。

スキーマの使用法については、basic schemaのパッケージが例として参考になりますが、Prosemirrorの優れた点は、独自のスキーマを定義できることです。

ノードタイプ

document内の各nodeにはtypeがあり、nodeの意味的な意味と、エディタ内でのレンダリング方法などの属性を表します。

スキーマを定義する際には、使用する各node typesをspec objectで記述して列挙する必要があります:

1
2
3
4
5
6
7
8
const trivialSchema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: { content: 'text*' },
text: { inline: true },
/* ... and so on */
},
});

上記のコードは、documentが1つ以上のparagraphsを含むことができるスキーマを定義しており、各paragraphは任意の数のtextを含むことができます。

各スキーマは、少なくとも最上位nodeのtype(デフォルトでは"doc"という名前ですが、設定可能です)と、text contentを規定する"text" typeを定義する必要があります。

inlineタイプとしてindexなどを計算するnodesは、そのinlineプロパティを宣言する必要があります(textタイプはinlineとして定義されていることを思い出してください——これは見落としがちな点かもしれません)。

コンテンツ式

上記のスキーマ例のcontentフィールドの文字列値は「コンテンツ式」と呼ばれます。これらは、現在のtypeのnodeに対して、どのchild nodesタイプが利用可能かを制御します。

例えば、「paragraph」は「1つのparagraph」、「paragraph+」は「1つ以上のparagraph」を意味します。同様に、「paragraph*」は「0個以上のparagraph」、「caption?」は「0個または1個のcaption node」を意味します。また、node名の後に正規表現のような範囲指定を使用することもでき、例えば{2}(正確に2個)、{1,5}(1個から5個)、または{2,}(2個以上)などです。

これらの式は組み合わせてシーケンスを作成できます。例えば、「heading paragraph+」は「最初にheading、その後1つ以上のparagraphs」を意味します。また、パイプ記号「|」演算子を使用して、2つの式のいずれかを選択するように指定することもできます。例えば、「(paragraph | blockquote)+」のように。

スキーマ内で複数回出現する可能性のある要素typeのグループがある場合、例えば「block」という概念のnodesがあり、これらが最上位要素の下またはblockquoteタイプのnode内にネストされる場合があります。groupプロパティを指定してnodeグループを作成し、他の式でグループ名を使用できます:

1
2
3
4
5
6
7
8
const groupSchema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'text*' },
blockquote: { group: 'block', content: 'block+' },
text: {},
},
});

上記の例では、「block+」は「(paragraph | blockquote)+」と同等です。

ブロックコンテンツを許可するノード(例ではdocとblockquote)では、少なくとも1つの子ノードを持つように設定することを推奨します。ノードが空の場合、ブラウザがそれを折りたたんで編集不可能にしてしまうためです(ここで言う「docやblockquoteのcontentをblock*ではなくblock+に設定」とは、子ノードが存在しない状態を許可しないことを意味します。正規表現の記法を流用しています:*は0個以上、+は1個以上を表します。この状態で編集すると、ブラウザはインラインノードであるテキストノードを入力しようとするため、実際には入力できません。読者は試してみてください——訳者注)。

スキーマにおいて、ノードの記述順序は重要です。必須ノードのデフォルトインスタンスを新規作成する場合、例えばreplace stepを適用した後、現在のドキュメントがスキーマの制約を満たし続けるためには、スキーマ制約を満たす最初のノードの表現が使用されます。ノードの表現がグループである場合、そのグループの最初のノードタイプ(スキーマ内での出現順序に依存)が使用されます。もし上記のスキーマ例で「paragraph」と「blockquote」の順序を入れ替えると、エディタがブロックノードを新規作成しようとした際にスタックオーバーフローが発生します——エディタはまず「blockquote」ノードを作成しようとしますが、このノードには少なくとも1つのブロックノードが必要なため、今度は内容として「blockquote」ノードを作成する必要が生じ、これが無限に繰り返されるからです。

Prosemirrorライブラリのすべてのノード操作関数が、処理対象のコンテンツの有効性をチェックするわけではありません——transformsのような高度な概念はチェックしますが、低レベルのノード作成メソッドは通常チェックせず、有効性の確認を呼び出し元に委ねます。これらの低レベルメソッドは(操作対象のコンテンツが無効であっても)完全に機能する可能性があります。例えばNodeType.createは、無効なコンテンツを含むノードを作成します。スライスの「open」側にあるノードについては、これはむしろ正当な場合もあります(スライスは「有効なノード」ではありませんが、スライスを直接操作する必要があるため——ユーザーに手動で補完させるわけにはいきません——訳者注)。スキーマに準拠しているかどうかをチェックするcreateCheckedメソッドや、コンテンツの有効性をアサートするcheckメソッドも存在します。

マーク

マークは通常、インラインコンテンツに追加のスタイルや情報を付与するために使用されます。schemaは、ドキュメントで許可されるすべてのスキーマを(ノードと同様に)宣言する必要があります。Mark typesはノードタイプに似たオブジェクトで、異なるマークを分類し追加情報を提供します。

デフォルトでは、インラインコンテンツを許可するノードは、スキーマで定義されたすべてのマークをその子ノードに適用できます。これはノードspecのmarksフィールドで設定可能です。

以下は、paragraphs内のテキストにstrongとemphasisマークを設定可能だが、headingではこれらのマークを許可しないシンプルなスキーマ例です。

1
2
3
4
5
6
7
8
9
10
11
12
const markSchema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'text*', marks: '_' },
heading: { group: 'block', content: 'text*', marks: '' },
text: { inline: true },
},
marks: {
strong: {},
em: {},
},
});

marksフィールドの値は、カンマ区切りのマーク名、またはマークグループ「_」(すべてのマークを許可するワイルドカード)で記述できます。空文字列はマークを一切許可しないことを意味します。

属性

ドキュメントスキーマはまた、ノードとマークが持つことができる属性を定義します。headingノードのレベル情報(H1、H2など——訳者注)のように、ノードタイプ固有の追加情報が必要な場合に適しています。

属性は、各ノードまたはマーク上に事前定義されたプロパティを持つプレーンなオブジェクトで、JSONシリアライズ可能な値を指します。許可される属性を指定するには、ノードspecやマークspecでオプションのattrプロパティを使用します:

1
2
3
4
heading: {
content: "text*",
attrs: {level: {default: 1}}
}

上記のスキーマでは、各headingノードインスタンスは.attrs.levelを通じてアクセス可能なlevel属性を持っています。](https://prosemirror.xheldon.com/docs/ref/#model.NodeType.create) headingを新規作成する際に指定がない場合、levelのデフォルト値は1となります。

ノード定義時に属性のデフォルト値を設定しない場合、そのノードを新規作成する際に属性が明示的に渡されないとエラーが発生します。これにより、ProsemirrorがcreateAndFillなどのインターフェースを呼び出してスキーマ制約を満たすノードを生成することが不可能になる場合もあります。

シリアライゼーションとパーシング

ブラウザで要素を編集可能にするためには、documentノードをDOM形式で表現する必要があります。最も簡単な方法は、スキーマ内で各ノードのDOM表現方法を指定することです。これは各ノードspecのtoDOMフィールドで実現できます。

このフィールドは、現在のノードを引数に取り、そのノードのDOM構造記述を返す関数を指すべきです。これは直接DOMノードか、](https://prosemirror.xheldon.com/docs/ref/#model.DOMOutputSpec)のような配列で記述できます。例:

1
2
3
4
5
6
7
8
9
10
11
12
const schema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: {
content: 'text*',
toDOM(node) {
return ['p', 0];
},
},
text: {},
},
});

上記の例で[“p”, 0]は、paragraphノードがHTMLで

タグとしてレンダリングされることを意味します。0は「穴」を表し、ノードのコンテンツがレンダリングされる位置を示します(つまり、ノードがコンテンツを持つことが想定される場合、配列の最後に0を記述する必要があります)。タグの後にHTML属性を表すオブジェクトを追加することも可能です(例:[“div”, {class: “c”}, 0])。leafノードはコンテンツを持たないため、DOM表現に「穴」を含める必要はありません。

Markのspecsもノードと同様のtoDOMメソッドを持ちますが、コンテンツを直接囲む単一タグとしてレンダリングする必要があるため、返されるノード内に直接コンテンツが含まれ、上記の「穴」を特別に指定する必要はありません。

HTML DOMコンテンツをProsemirrorが認識可能なdocumentに変換する必要も頻繁に発生します(例:ユーザーがエディタにコンテンツをペーストまたはドラッグした場合)。Prosemirror-modelモジュールにはこれらの処理を行う関数がありますが、スキーマのparseDOM属性に変換方法を直接含めることも可能です。

ここでは、DOMをノードまたはマークにマッピングする方法を記述した](https://prosemirror.xheldon.com/docs/ref/#model.ParseRule)変換ルールのセットがリストされています。例えば、基本スキーマのemphasisマークは次のように記述されます:

1
2
3
4
5
parseDOM: [
{ tag: 'em' }, // Match <em> nodes
{ tag: 'i' }, // and <i> nodes
{ style: 'font-style=italic' }, // and inline 'font-style: italic'
];

parseルールの](https://prosemirror.xheldon.com/docs/ref/#model.ParseRule.tag)tagフィールドはCSSセレクタでも指定可能で、"div.myclass"のような文字列を渡すこともできます。同様に、](https://prosemirror.xheldon.com/docs/ref/#model.ParseRule.style)styleフィールドはインラインCSSスタイルにマッチします。

スキーマがparseDOMフィールドを含む場合、DOMParser.fromSchemaを使用して](https://prosemirror.xheldon.com/docs/ref/#model.DOMParser)DOMParserオブジェクトを作成できます。エディタはデフォルトのクリップボードコンテンツパーサーを作成する際にこの方法を使用しますが、](https://prosemirror.xheldon.com/docs/ref/#view.EditorProps.clipboardParser)オーバーライドすることも可能です。

Documentには組み込みのJSONシリアライゼーション方法もあります。ノードでtoJSONを呼び出すと、JSON.stringify関数に安全に渡せるオブジェクトが生成されます(デバッグ目的と思われます)。さらに、schemaオブジェクトにはtoJSONの結果を元のノードに戻すnodeFromJSONメソッドがあります。

スキーマの拡張

](https://prosemirror.xheldon.com/docs/ref/#model.Schema)Schemaコンストラクタに渡すnodesとmarksオプションの引数は、OrderedMap型のオブジェクトかプレーンなJavaScriptオブジェクトのいずれかです。生成されたスキーマの.](https://prosemirror.xheldon.com/docs/ref/#model.Schema.spec)spec.nodesと.spec.marksプロパティは常にOrderedMapsであり、他のスキーマの基礎として使用できます。

OrderedMaps は、新しいスキーマを簡単に作成するための多くのメソッドをサポートしています。例えば、schema.markSpec.remove("blockquote") を呼び出し、その結果を Schema コンストラクタの nodes パラメータに渡すことで、blockquote ノードを持たないスキーマを生成できます。

ドキュメント変換

Transform は ProseMirror のコアな動作方式です。これは transactions の基礎であり、編集履歴の追跡や共同編集を可能にします。

なぜ?

なぜドキュメントを直接変更(ミューテート)できないのでしょうか?あるいは、少なくともドキュメントの新しいバージョンを作成してエディタに適用するだけではなぜ不十分なのでしょうか?

いくつかの理由があります。その一つはコードの明瞭さです。イミュータブルなデータ構造は確かにシンプルなコードを生み出します。また、transform システムが主に行うのは、ドキュメント更新の痕跡を保持することです。transform の一連の値は、古いドキュメントから新しいドキュメントへの各ステップの記録を表します。

Undo History はこれらのステップを保存し、必要に応じて逆に適用できます(ProseMirror は選択的 undo を実装しており、単に以前の state に戻すよりも複雑です)

Collaborative editing(共同編集)システムはこれらのステップを送信し、必要に応じて記録することで、各ドキュメント編集者が同じドキュメントを持つことを可能にします。

ほとんどの場合、ドキュメントの変更(自分自身または共同編集からのもの)に対応できることは、エディタプラグインにとって有用です。これにより、プラグインは常にエディタの state と同じ状態を保つことができます。

ステップ

ドキュメントの更新は、更新を記述する個々の steps に分解されます。通常、直接扱う必要はありませんが、それらがどのように機能するかを知っておくことは重要です。

ステップの例としては、ドキュメントの一部を置換する ReplaceStep や、範囲に Mark を適用する AddMarkStep があります。

ステップはドキュメントに applied され、新しいドキュメントを生成できます。

1
2
3
4
5
console.log(myDoc.toString()); // → p("hello")
// 删除了 position 在 3-5 的 setp
let step = new ReplaceStep(3, 5, Slice.empty);
let result = step.apply(myDoc);
console.log(result.doc.toString()); // → p("heo")

ステップを適用するのは比較的単純なプロセスです。スキーマの制約を満たすためにノードを挿入したり、スライスを変換してスキーマに適合させたりするようなことは行いません。これは、ステップの適用が失敗する可能性があることを意味します。たとえば、ノードの一方のトークン(ノードの開始または終了タグ)を削除しようとすると、もう一方のトークンが正しく閉じられなくなり、意味をなさなくなります。これが、apply メソッドが result object を返す理由です(ステップの適用が成功した場合は新しいドキュメントへの参照を保持し、失敗した場合はエラーメッセージを含みます)。

通常は、helper function を使用してステップを生成させ、細かい部分を気にせずに済むようにします。

トランスフォーム

編集アクションは1つ以上のステップを生成する可能性があります。一連のステップを処理する最も便利な方法は、Transform object を作成することです(または、エディタの全体の state を処理する場合は、Transform のサブクラスである Transaction を使用します)。

1
2
3
4
5
let tr = new Transform(myDoc);
tr.delete(5, 7); // Delete between position 5 and 7
tr.split(5); // Split the parent node at position 5
console.log(tr.doc.toString()); // The modified document
console.log(tr.steps.length); // → 2

ほとんどの transform メソッドは transform 自体を返すため、チェーン呼び出しが便利です(例: tr.delete(5, 7).split(5))。

Transform には、deletingreplacingadding および removing marks、ツリーデータ構造を操作する splittingjoiningliftingwrapping などのメソッドがあります。

ドキュメントに変更を加える際、そのドキュメント内の特定の位置(position)が無効になったり、元々の意味を失ったりすることがあります。例えば、文字を挿入すると、その文字より後ろのすべての文字の位置が1つ増加します。つまり、後続の文字は新しい位置を指すことになります。同様に、ドキュメントのすべてのコンテンツを削除すると、以前にコンテンツを指していた位置はすべて無効になります。

ドキュメントの変更過程において、位置情報を保持する必要が頻繁に生じます(例えば選択範囲の境界など)。この問題に対処するため、stepsは](https://prosemirror.xheldon.com/docs/ref/#transform.StepMap)を提供し、step適用前後のドキュメント内位置情報を変換できます。

1
2
3
4
let step = new ReplaceStep(4, 6, Slice.empty); // Delete 4-5
let map = step.getMap();
console.log(map.map(8)); // → 6
console.log(map.map(2)); // → 2 (document 变化的地方之前的 position 未变化)

Transformオブジェクトは、一連のstepによって生成されるmapを自動的に](https://prosemirror.xheldon.com/docs/ref/#transform.Transform.mapping)します。これは](https://prosemirror.xheldon.com/docs/ref/#transform.Mapping)という抽象化を使用して実装されており、複数のstepのmapsを収集すると同時に、それらを一括でマッピング可能にします。

1
2
3
4
5
6
let tr = new Transaction(myDoc);
tr.split(10); // split a node, +2 tokens at 10
tr.delete(2, 5); // -3 tokens at 2
console.log(tr.mapping.map(15)); // → 14
console.log(tr.mapping.map(6)); // → 3
console.log(tr.mapping.map(10)); // → 9

しかし、ある位置をどこにマッピングすべきかという問題があります。上の例の最後の行を見てください。位置10はちょうどノードが分割される位置にあり、そこに2つのトークンが挿入されました。この位置は挿入内容の前と後、どちらにマッピングされるべきでしょうか?この例では明らかに挿入内容の後ろに配置されています。

ただし、場合によっては異なるマッピング動作が必要になることもあります。そのため、](https://prosemirror.xheldon.com/docs/ref/#transform.Mappable.map)メソッドはstep mapとmapping時に第二引数としてbiasを受け取り、-1を設定することで挿入位置を挿入内容の前に配置できます。

1
console.log(tr.mapping.map(10, -1)); // → 7

このようなマッピングを可能にし、ロスレスでstepを](https://prosemirror.xheldon.com/docs/ref/#transform.Step.invert)し、position mapsを相互にマッピングするために、各stepを小さく直接的なものにしているのです。

リベーシング

(このセクションの内容は完全に理解できていないため、ドキュメントを忠実に翻訳しています。誤りがあればご指摘ください)

stepとmapに関連するより複雑な処理(独自の変更追跡の実装や共同編集機能の統合など)を行う場合、stepのリベーシングが必要になることがあります。

実際に必要となるまで、この部分を学ぶのは面倒に感じるかもしれません。

リベーシングを簡単な例で説明すると、同じドキュメントが2つのstepで変更された場合、一方のstepを変換してもう一方のstepで変更されたドキュメントに適用できるようにする処理です。擬似コードは以下の通りです:

1
2
3
4
5
stepA(doc) = docA
stepB(doc) = docB
stepB(docA) = MISMATCH!
rebase(stepB, mapA) = stepB'
stepB'(docA) = docAB

Stepsには](https://prosemirror.xheldon.com/docs/ref/#transform.Step.map)メソッドがあり、mappingを指定してstep全体をマッピングします。このマッピング処理は、stepがマッピング時にすでに意味をなさない場合(例えば適用対象が削除されているなど)失敗する可能性があります。ただしマッピングが成功した場合、新しいドキュメント(マッピング後のドキュメント)を指すstepが得られます。したがって、上記の擬似コード例では、rebase(stepB, mapA)は単純にstepB.map(mapA)で呼び出せます。

連鎖したstepsを別の連鎖したstepsにリベースする場合:

1
2
3
stepA2(stepA1(doc)) = docA
stepB2(stepB1(doc)) = docB
???(docA) = docAB

stepB1をstepA1、stepA2経由でstepB1’にマッピングできますが、stepB2はstepB1(doc)で生成されたドキュメントから始まり、そのマッピング版はstepB1’(docA)で生成されたドキュメントに適用する必要があるため、処理がより複雑になります。以下の連鎖マップを経由してマッピングする必要があります:

1
rebase(stepB2, [invert(mapB1), mapA1, mapA2, mapB1'])

例えば、まず、stepB1のマップを反転させることでドキュメントが初期ドキュメントに戻り、その後(stepB1)にstepA1とstepA2によって生成されたマップのストリーム(チェーン呼び出し)が適用され、最後にsetpB1によって生成されたマップを適用することでドキュメントがdocAに変わります。

ここにsetpB3がある場合、以前のマップストリームを使用してstepB3のマップストリームを取得し、その前にinvert(mapB2)を追加し、mapB2’をストリームの末尾に配置する、といった方法で対応できます。

ただし、stepB1が何らかのコンテンツを挿入し、stepB2がそのコンテンツに対して何らかの操作を行う場合、invert(mapB1)をマッピングしたstepB2はnullを返します。これは、stepB1の反転が適用しようとするコンテンツを削除してしまうためです。しかし、このコンテンツは後でmapB1によって再びストリームに導入されます。マッピングという抽象オブジェクトは、このようなストリームを追跡する手段を提供し、関連するマップを反転させる方法を含んでいます。上記のシナリオを解決するために、マッピングオブジェクトを使用してステップをマップすることができます。

すでにリベースされたステップを持っている場合でも、それを現在のドキュメントに適用する際にまだ有効である保証はありません。例えば、ステップがいくつかのマークを追加する場合でも、別のステップがマークを追加したいコンテンツの親ノードを変更し、その親ノードが以前のステップでマークを追加できないノードに変わってしまうことがあります。この場合、ステップを適用しようとすると失敗します。このような状況では、ステップを削除するのが適切な対応です。

エディターの状態

エディターの状態は何で構成されているのでしょうか?もちろん、それを構成するドキュメントはすでにあります。しかし、それ以外にもselection(選択範囲)があります。また、マークの設定変更を保存する方法も必要です。例えば、編集を開始していない状態でマーク(太字やフォントサイズなど)を有効または無効にする場合です(つまり、マークをクリックしてから編集を開始するという一般的なニーズに対応するためです)。

Prosemirrorの状態には主に3つのコンポーネントがあり、それらはstateオブジェクト上に存在します:docselection、そしてstoreMarksです。

1
2
3
4
5
6
import { schema } from 'prosemirror-schema-basic';
import { EditorState } from 'prosemirror-state';

let state = EditorState.create({ schema });
console.log(state.doc.toString()); // An empty paragraph
console.log(state.selection.from); // 1, the start of the paragraph

ただし、プラグインも状態を保存する必要がある場合があります。例えば、元に戻す履歴プラグインは変更履歴を保存する必要があります。これが、アクティブなプラグインの設定も状態に保存される理由であり、これらのプラグインは独自のスロットを定義して独自の状態を保存することもできます。

選択範囲

Prosemirrorはさまざまなタイプの選択範囲をサポートしており(また、サードパーティのコードが新しい選択範囲タイプを定義することも許可しています)、これらの異なるタイプの選択範囲はSelectionのサブクラスとして表現されます。ドキュメントやその他の状態関連の値と同様に、これらは不変(immutable)です。つまり、選択範囲を変更するには、新しい選択範囲オブジェクトとそれを保持する新しい状態を作成する必要があります。

選択範囲には、少なくとも開始位置(.from)と終了位置(.to)が現在のドキュメント内を指しています。多くの選択範囲タイプはanchor(選択範囲の固定側)とhead(選択範囲の可動側)を区別するため、これらの属性はすべての選択範囲オブジェクトに存在します。

最も一般的に使用される選択範囲タイプはtext selectionで、通常のカーソル(anchorとheadが同じ場合)またはテキスト選択を表すために使用されます。テキスト選択の両端はインラインポジションである必要があります。つまり、インラインコンテンツを許可するノード内である必要があります。

Prosemirrorのコアライブラリはnode selectionもサポートしており、この選択範囲は単一のノードが選択されている場合を表します。例えば、ノードをctrl/cmd + clickした場合などです。このタイプの選択範囲の範囲は、そのノードの前からノードの後までの位置です。

トランザクション

通常の編集時には、新しいstateは古いstateから派生します。しかし、ドキュメントを読み込む際に全く新しいstateを作成したい場合など、例外も存在します(つまり古いstateから派生しない場合)。

stateは、既存のstateにapplingtransactionを適用することで更新され、新しいstateが生成されます。概念的には、これは一度だけ行われます:古いstateと変更のtransactionが与えられると、stateの各コンポーネントの新しい値が計算され、それらが新しいstateの値を構成します。

1
2
3
4
5
let tr = state.tr;
console.log(tr.doc.content.size); // 25
tr.insertText('hello'); // Replaces selection with 'hello'
let newState = state.apply(tr);
console.log(tr.doc.content.size); // 30

TransactionTransformのサブクラスであり、前のドキュメントにstepsを適用することでドキュメントを更新するメソッドを継承しています。さらに、transactionはselectionや他のstate関連のコンポーネントを追跡し、replaceSelectionのようなselection関連の便利なメソッドも提供します。

transactionを作成する最も簡単な方法は、エディタのstateオブジェクトでtr getterつまりview.state.tr)を呼び出すことです。これは現在のstateに基づいて空のtrを作成し、そこにstepsや他の更新を追加できます。

デフォルトでは、古いselectionは各stepを通じてmappedされ、新しいselectionが生成されますが、setSelectionを使用して正確に新しいselectionを設定することも可能です。

1
2
3
4
5
6
let tr = state.tr;
console.log(tr.selection.from); // → 10
tr.delete(6, 8);
console.log(tr.selection.from); // → 8 (moved back)
tr.setSelection(TextSelection.create(tr.doc, 3));
console.log(tr.selection.from); // → 3

同様に、アクティブなmarksの集合(storeMarks)は、ドキュメントやselectionが変更されると自動的にクリアされますが、setStoredMarksensureMarksで再設定できます。

最後に、scrollInteViewメソッドは、次回のstateが(ブラウザで)現在のビューに描画されることを保証します。多くのユーザー操作後にこれを呼び出すと良いでしょう。

Transformのメソッドと同様に、ほとんどのTransactionはチェーン呼び出しを容易にするため、transaction自体を返します。

プラグイン

新しいstateをcreatingする際、プラグインの配列を提供できます。これらはあらゆるstateに存在し、これらのプラグインはtransactionの適用方法やstateの動作に影響を与えます。

プラグインはPluginクラスのインスタンスであり、さまざまな機能を実装できます。最も単純なものは、イベントに応答してeditor viewにpropsを追加するもので、より複雑なものはeditorに新しいstateを追加し、transactionに基づいて更新します。

新しいプラグインを作成する際は、その動作を指定するオブジェクトを渡す必要があります:

1
2
3
4
5
6
7
8
9
10
let myPlugin = new Plugin({
props: {
handleKeyDown(view, event) {
console.log('A key was pressed!');
return false; // We did not handle this
},
},
});

let state = EditorState.create({ schema, plugins: [myPlugin] });

プラグインが独自のstate slot(Vueで言うところのスコープ付きスロット)を必要とする場合、stateプロパティを定義できます:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let transactionCounter = new Plugin({
state: {
init() {
return 0;
},
apply(tr, value) {
return value + 1;
},
},
});

function getTransactionCount(state) {
return transactionCounter.getState(state);
}

上記の例では、このプラグインはstateに適用されたtransactionの数を単純にカウントしています。このヘルパー関数は、editorのstateオブジェクトからプラグインのstateを取得できるgetStateメソッドを使用しています。

editorのstateは永続的で不変(immutable)なオブジェクトであり、プラグインstateはその一部であるため、プラグインstateの値も不変でなければなりません。例えば、プラグインstateを変更する必要がある場合、applyメソッドは古い値を変更するのではなく新しい値を返す必要があり、他のコードはそれらを変更してはなりません。

プラグインにとって、トランザクションに追加情報を付与することは一般的に有用です。例えば、undo履歴において、undo操作を実行する際、実行結果のトランザクションにマークを付与します。プラグインがこのマークを検出すると、そのトランザクションを特別扱いし、undoスタックの先頭アイテムを削除すると同時に、通常のドキュメント変更ではなくredoスタックにこのトランザクションを追加します。

この目的(トランザクションへの追加情報付与)を実現するため、トランザクションにはmetadataを付加できます。前述のトランザクションカウントプラグイン(上記の例)を更新し、マーク付きトランザクションをカウントしないように修正できます。以下の通りです:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let transactionCounter = new Plugin({
state: {
init() {
return 0;
},
apply(tr, value) {
if (tr.getMeta(transactionCounter)) return value;
else return value + 1;
},
},
});

function markAsUncounted(tr) {
tr.setMeta(transactionCounter, true);
}

metadataのキーは文字列でも構いませんが、命名衝突を避けるため、プラグインオブジェクト(PluginKeyオブジェクト、Symbolと同様の原理)を使用することを強く推奨します。一部のキーは既にProsemirrorによって予約されています。例えば"addToHistory"をfalseに設定すると、そのトランザクションのundoを防止できます。pasteイベントを処理する際、エディタはトランザクションのpasteプロパティをtrueに設定します。

ビューコンポーネント

Prosemirrorのeditor viewはユーザーインターフェースコンポーネントであり、editor stateをユーザーに表示するとともに、編集操作の実行を可能にします。

ここで言う「編集操作」の定義は、コアビューコンポーネントにとってより狭義です。ビューコンポーネントは直接的に編集インターフェースの相互作用(クリック入力、コピペ、ドラッグなど)を処理しますが、それ以外の機能は多くありません。つまり、メニュー表示やキーボードバインディングの提供、コアビューコンポーネント外でのビューコンポーネントへの応答などは、プラグインによって実現する必要があります。

編集可能なDOM

エディタはDOMの一部をeditableとして指定でき、この属性によりフォーカスと選択が可能になり、コンテンツ入力が実現します。ビューコンポーネントはドキュメントのDOM表現を作成し(デフォルトではスキーマのtoDOMメソッドを使用)、それを編集可能にします。編集可能要素がフォーカスされると、ProsemirrorはDOMのSelectionがeditor stateのselectionと一致するように保証します。

ほとんどのDOMイベントに対して、適切なtransactionに変換する登録済みイベントハンドラが多数用意されています。例えばペースト時には、内容がProsemirrorドキュメントのスライスとしてフォーマットされ、ドキュメントに挿入されます。

多くのイベントは、Prosemirrorによるラップ処理を経ず、直接ユーザー処理が許可された後、Prosemirrorのデータモデルで再解釈されます。例えば、ブラウザは(特に双方向テキスト処理において)カーソルと選択範囲の位置管理に長けているため、ほとんどのカーソル移動関連のキーやマウスイベントはブラウザに委ねられ、処理完了後にProsemirrorが現在のDOM selectionがどのtext selectionタイプに該当するかを検査します。実際のselectionとProsemirrorの現行selectionに不一致が検出されると、selection更新用のトランザクションがdispatchされます。

入力イベントも通常はブラウザ処理に委ねられます。なぜなら、入力イベントへの干渉は、モバイル端末のスペルチェックや自動大文字化などのネイティブ機能を無効化する可能性があるためです。ブラウザがDOMを更新すると、エディタがこれを検知し、ドキュメント変更部分を再フォーマット後、それらの変更をトランザクションに変換します。

データフロー

したがって、editor viewは与えられたeditor stateを表示し、何らかのイベントが発生したときに新しいtransactionを作成してそれをブロードキャストします(この新しく作成されたtransactionは他のpluginやイベントで使用できるようにするため)。その後、このtransactionは通常、新しいstateの作成に使用され、その新しいstateはviewのupdateStateメソッドによって使用されます:

prosemirror-data-flow

図からわかるように、Prosemirrorはシンプルな循環データフローを構築しており、これはJavaScript界で典型的な命令型のイベント処理実装(より複雑なデータフローネットワークを生成する傾向がある)とは全く異なります。

transactionを「インターセプト」することは可能です。なぜなら、それらはdispatchTransactionプロパティを介してdispatchedされるため、Prosemirrorのデータフローをより大きなデータループに組み込むことができます。例えば、React/VueのようなビューフレームワークのデータフローやReduxなどの類似アーキテクチャを使用している場合、Prosemirrorのtransactionをメインのイベントディスパッチループに統合し、Prosemirrorのstateをアプリケーションの「store」に配置することが可能です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// The app's state
let appState = {
editor: EditorState.create({ schema }),
score: 0,
};
let view = new EditorView(document.body, {
state: appState.editor,
dispatchTransaction(transaction) {
update({ type: 'EDITOR_TRANSACTION', transaction });
},
});

// A crude app state update function, which takes an update object,
// updates the `appState`, and then refreshes the UI.
function update(event) {
if (event.type == 'EDITOR_TRANSACTION')
appState.editor = appState.editor.apply(event.transaction);
else if (event.type == 'SCORE_POINT') appState.score++;
draw();
}
// An even cruder drawing function
function draw() {
document.querySelector('#score').textContent = appState.score;
view.updateState(appState.editor);
}

効率的な更新

updateState機能を実装する一つの方法は、呼び出されるたびにドキュメント全体を再レンダリングすることです。しかし、大きなドキュメントの場合、これは非常に遅くなります。

そのため、viewを更新する際、viewは現在のドキュメントと新しいドキュメントを比較し、古いドキュメントのうちDOMに変更がない部分はそのまま保持されます。Prosemirrorはこの作業を代行し、各更新で非常に小さな作業しか必要としません。

テキスト入力の更新などの場合、ブラウザ自身の編集操作によってDOMにすでにテキストが追加されているため、ProsemirrorとDOMを一致させるためにDOM更新は不要です(このようなDOM状態をProsemirrorに同期するtransactionがキャンセルされた場合、viewはDOMを修正してstateとの同期を保ちます)。

同様に、DOM selectionはstateのselectionと乖離している場合にのみ同期され、ブラウザselectionのさまざまな隠れた状態(例えば短い行で上下矢印キーを押したときの動作で、カーソルが前後の長い行の行末に移動する機能)を破壊しないようにします。

Props

'Props’は非常に有用な概念で、正確にはReactから借用したものです。PropsはUIコンポーネントに対するパラメータのようなものです。理想的には、コンポーネントが受け取るpropsがその動作を完全に定義します。

1
2
3
4
5
6
7
8
9
let view = new EditorView({
state: myState,
editable() {
return false;
}, // Enables read-only behavior
handleDoubleClick() {
console.log('Double click!');
},
});

上記のように、現在のstateはpropの一つです。コンポーネントを制御するコード(つまりpropsを渡すコード)は、stateを除く他のpropsを随時updatesできますが、コンポーネント自体はstate以外のpropsを変更しません(これらは制御コードによって更新されるべきです)。updateStateは単にstate propを更新するショートカットです。

Pluginもpropsをdeclareできますが、statedispatchTransactionは含まれません。これらはview定義時に直接提供する必要があります(Pluginはstateフィールドを定義できますが、これはpluginの状態を表し、ここでのstateはeditorのstateを指します)。

1
2
3
4
5
6
7
8
9
function maxSizePlugin(max) {
return new Plugin({
props: {
editable(state) {
return state.doc.content.size < max;
},
},
});
}

複数のPluginなどによってpropが複数回宣言された場合、それらのpropの処理方法は各prop自身に依存します。大まかに言えば、(editor view)から直接提供されるpropsが優先され、その後は各pluginが宣言された順序で処理されます。一部のprops、例えば[[domParser]]](https://prosemirror.xheldon.com/docs/ref/#view.EditorProps.domParser)では、最初に宣言された値が使用され、後から宣言されたものは無視されます。処理関数の場合、イベントを処理したかどうかを示すboolean値を返し、最初にtrueを返した関数がそのイベントを処理します(その後、同種のイベント処理関数は無視されます)。最後に、[[attributes]]](https://prosemirror.xheldon.com/docs/ref/#view.EditorProps.attributes)(編集可能なDOMに属性を設定できる)やdecorations(次のセクションで説明)などの他のpropsについては、それらをマージした値が使用されます。

Decorations

Decorationsを使用すると、ドキュメントビューの描画方法をある程度制御できます。これらは[[decorationsプロパティ]]](https://prosemirror.xheldon.com/docs/ref/#view.EditorProps.decorations)の戻り値によって作成され、3つのタイプがあります:

効率的な描画と比較を行うため、これらのdecorationは[[decoration set]]](https://prosemirror.xheldon.com/docs/ref/#view.DecorationSet)(実際のドキュメント構造に似たツリーデータ構造)の形式で提供される必要があります。静的メソッド[[create]]](https://prosemirror.xheldon.com/docs/ref/#view.DecorationSet^create)を使用して新規作成でき、この関数には現在のドキュメントとdecoration配列のオブジェクトを引数として渡します:

1
2
3
4
5
6
7
8
9
10
11
let purplePlugin = new Plugin({
props: {
decorations(state) {
return DecorationSet.create(state.doc, [
Decoration.inline(0, state.doc.content.size, {
style: 'color: purple',
}),
]);
},
},
});

多数のdecorationがある場合、毎回再描画時にメモリ内でdecoration setを作成するのはコストがかかります。そのため、このような状況では、decorationをpluginのstateで管理し、ドキュメントが変更されたときに新しいドキュメント状態にマッピングし、必要な場合にのみ更新することを推奨します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let specklePlugin = new Plugin({
state: {
init(_, { doc }) {
let speckles = [];
for (let pos = 1; pos < doc.content.size; pos += 4)
speckles.push(
Decoration.inline(pos - 1, pos, { style: 'background: yellow' })
);
return DecorationSet.create(doc, speckles);
},
apply(tr, set) {
return set.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) {
return specklePlugin.getState(state);
},
},
});

例示されているプラグインは、そのstateをdecoration setとして初期化し、4つごとの位置に黄色のインラインバックグラウンドdecorationを追加しています。これはあまり有用ではないかもしれませんが、このようなアプローチを使用して、検索結果のハイライトやコメント領域の追加などの機能を実現できます。

transactionがstateに適用されると、プラグインstateの[[applyメソッド]]](https://prosemirror.xheldon.com/docs/ref/#state.StateField.apply)がdecoration setを前方にマッピングし、decoration set(生成された要素)を新しいドキュメント構造に「適応」させて保持します。mappingメソッド(ローカルな変更に頻繁に使用される)は、decoration setのツリー構造により効率的に更新されます——変更の影響を受けたノードのみが更新されます。

(本番環境のプラグインのapplyメソッドは、新しいイベントがdecorationの追加や削除をトリガーした場合にも実行されます。その際は、transactionが運ぶ情報をチェックするか、プラグインのtransactionに付加されたmeta情報を検査することで検出できます)

最終的に、decorationsプロパティは単にプラグインのstateを返し、これによりviewにdecorationが表示されます。

Node views

ドキュメントの描画方法に影響を与える別の方法として[[Node views]]](https://prosemirror.xheldon.com/docs/ref/#view.NodeView)があります。これらは、ドキュメント内の個々の小さな独立したnodeのUIコンポーネントを定義することで実現されます。これら(定義したnode views)により、これらのDOMのレンダリング方法を定義し、更新方法を指定し、イベントに応答するカスタムコードを記述できます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let view = new EditorView({
state,
nodeViews: {
image(node) {
return new ImageView(node);
},
},
});
class ImageView {
constructor(node) {
// The editor will use this as the node's DOM representation
this.dom = document.createElement('img');
this.dom.src = node.attrs.src;
this.dom.addEventListener('click', (e) => {
console.log('You clicked me!');
e.preventDefault();
});
}
stopEvent() {
return true;
}
}

例示されているimageノードのviewオブジェクトは、image用のカスタムDOMノードを作成し、イベントハンドラとstopEventメソッドを追加しています。このメソッドは、ProsemirrorがこのDOMノードからのイベントを無視する必要があることを示しています。

ドキュメント内の実際のノードに影響を与えるために、頻繁にノードと対話したい場合があります。しかし、ノードを変更するトランザクションを作成するには、まずそのノードの位置を知る必要があります。これを可能にするため、ノードビューは現在のドキュメント内での位置をクエリするためのゲッター関数を提供します。先ほどの例を修正して、このノードをクリックしたときに画像ノードのalt情報を入力できるようにしましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
let view = new EditorView({
state,
nodeViews: {
image(node, view, getPos) {
return new ImageView(node, view, getPos);
},
},
});
class ImageView {
constructor(node, view, getPos) {
this.dom = document.createElement('img');
this.dom.src = node.attrs.src;
this.dom.alt = node.attrs.alt;
this.dom.addEventListener('click', (e) => {
e.preventDefault();
let alt = prompt('New alt text:', '');
if (alt)
view.dispatch(
view.state.tr.setNodeMarkup(getPos(), null, {
src: node.attrs.src,
alt,
})
);
});
}
stopEvent() {
return true;
}
}

setNodeMarkupは、指定された位置のノードのタイプや属性を変更するために使用できるメソッドです。上記の例では、getPosメソッドを使用して画像ノードの現在の位置を検索し、このノードに新しい属性とalt情報を割り当てています。

ノードが更新されるときのデフォルトの動作は、外側のDOM構造を保持し、その子要素と新しい子要素の集合を比較し、必要に応じてそれらを更新または置換することです。ノードビューはこのデフォルトの動作を上書きでき、ノードの内容に基づいて段落のCSSクラス名を更新するなどの操作が可能になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let view = new EditorView({
state,
nodeViews: {
paragraph(node) {
return new ParagraphView(node);
},
},
});
class ParagraphView {
constructor(node) {
this.dom = this.contentDOM = document.createElement('p');
if (node.content.size == 0) this.dom.classList.add('empty');
}
update(node) {
if (node.type.name != 'paragraph') return false;
if (node.content.size > 0) this.dom.classList.remove('empty');
else this.dom.classList.add('empty');
return true;
}
}

画像には内容がないため、先ほどの例ではその内容のレンダリング方法を心配する必要はありませんでした。しかし、段落には内容があります。ノードビューはその内容を操作するための2つのアプローチをサポートしています:Prosemirrorに管理させるか、完全に手動で管理するかです。contentDOM属性を提供すると、Prosemirrorはノードの内容をその属性ノード内にレンダリングし、ノードの内容更新を処理します。この属性を提供しない場合、ノードの内容はエディタにとってブラックボックスとなり、内容の表示方法やユーザーとの対話方法は完全にあなた次第です。

このケースでは、段落の内容が通常の編集可能なテキストのように振る舞うことを望んでいるため、contentDOM属性はdom属性と同じように定義されます。内容は外側のコンテナに直接レンダリングされる必要があるからです。

魔法はupdateメソッドで起こります。まず、このメソッドはノードビューが変更されたノードをどのように表示するかを完全に決定します。エディタの更新アルゴリズムによって描画される新しいノードは何でもあり得るため、新しいノードが現在のノードビューで処理できることを確認する必要があります。

例のupdateメソッドはまず、新しいノードが段落であるかどうかを確認し、そうでない場合は即座に中断します。次に、新しいノードの内容に基づいて、emptyクラス名がノードに存在すべきかどうかを確認し、trueを返すと更新が成功したことを示します(この時点でノードの内容が更新されます)。

コマンド

Prosemirrorの用語では、コマンド関数はユーザーが特定のキーの組み合わせ(例:cmd + aで全選択)を押すか、メニュー操作を実行することでアクションを実行できるようにします。

実用的な理由から、コマンドはやや複雑です。シンプルなコマンドは、editor stateとdispatch(EditorView.dispatchまたはトランザクションに関連する他の関数)を引数として受け取り、boolean値を返す関数です。以下は非常にシンプルな例です:

1
2
3
4
5
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false;
dispatch(state.tr.deleteSelection());
return true;
}

コマンドが利用できない場合、falseを返して何もしません。利用可能な場合、トランザクションをdispatchし、trueを返します。keymapプラグインはこのメカニズムを使用して、すでにいずれかのコマンドによって処理されたキーが他のコマンドによって処理されるのを防ぎます。

コマンドが特定のstateに適用可能かどうかを実際に実行せずにクエリするために、dispatchパラメータはオプションです。コマンド関数がdispatchなしで呼び出され、利用可能な場合はtrueを返すだけで、他の操作は行いません。以下の例はこの状況を示しています:

1
2
3
4
5
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false;
if (dispatch) dispatch(state.tr.deleteSelection());
return true;
}

現在の選択範囲が削除可能かどうかを確認するには、deleteSelection(view.state, null)を呼び出します。実際に選択範囲を削除する場合は、deleteSelection(view.state, view.dispatch)を呼び出します。メニューバーはこのメカニズムを使用して、メニューボタンをグレーアウト(無効化)すべきかどうかを判断できます。

前述のメニューバーでcommandsを使用する際、実際のeditor viewにはアクセスしません——実際、ほとんどの場合、commandsはアクセスを必要とせず、viewが利用できない状況でも設定を通じてメニューコマンドを適用・テストすることが可能です。しかし、一部のcommandsはDOMとの相互作用を必要とします——例えば、特定のpositionがtextblockの末尾にあるかどうかをqueryしたり、viewを基準に位置を決めたダイアログを表示したい場合があります。そのため、commandsを呼び出す多くのpluginは、第三引数として現在のviewを渡します。

1
2
3
4
5
6
7
function blinkView(_state, dispatch, view) {
if (dispatch) {
view.dom.style.background = 'yellow';
setTimeout(() => (view.dom.style.background = ''), 1000);
}
return true;
}

この例(あまり有用ではありませんが)は、commandsがtransactionをdispatchする必要がないことを示しています——通常、commandsは副作用(transactionのdispatch)を適用するために呼び出されますが、ダイアログを表示するためだけに呼び出すことも可能です(dispatchせずに)。

prosemirror-commandsモジュールは、多くの編集commandsを提供しています。単純なdeleteSelectionのバリエーションから、より複雑なjoinBackwardまで、これはtextblockの行頭でバックスペースを押したときに発生するblock-joining動作を実装しています。このモジュールにはまた、basic keymap(基本的なキーバインド)が含まれており、多くのプラットフォーム非依存(Win/MacやSafari/Chromeなどの違いを考慮しない)commandsを対応するキーにバインドしています。

場合によっては、通常は単一のキーにバインドされる異なる動作が別々のcommandsに分けられることもあります(つまり、1つのキーが状況に応じて異なるcommandで処理されることがあります)。ユーティリティ関数chainCommandsは、複数のcommandsを組み合わせるために使用できます——これらは、trueが返されるまで順番に試行されます。

例えば、基本的なキーマップは、バックスペースキーをcommand chain deleteSelection(selectionが空でない場合に有効)、joinBackward(カーソルがtextblockの先頭にある場合に有効)、そしてselectNodeBackward(schemaが通常のノード結合操作を禁止している場合、selectionの前のノードを選択)にバインドしています。これらのいずれも適用されない場合、ブラウザはデフォルトの動作を実行します。これは、textblock内でバックスペースを押す場合に適切です(これにより、ネイティブのスペルチェックなどが正常に機能します)。

commandsモジュールはまた、toggleMarkなどのcommandコンストラクタもエクスポートしています。これはmarkタイプとオプションの属性セットを受け取り、現在のselectionのmarkをトグルするcommand関数を返します。

他のモジュールもcommand関数をエクスポートすることがあります。例えば、historyモジュールのundoredo関数です。独自のエディタをカスタマイズしたり、ユーザーがカスタムdocument nodeと対話できるようにするためには、独自のcommand関数を書く必要があるかもしれません。

共同編集

リアルタイムの共同編集では、複数のユーザーが同時に同じdocumentを編集できます。ユーザーがドキュメントに加えた変更は、まずローカルのdocumentに適用され、その後他のユーザーに送信されます。異なるユーザーからの変更は自動的にマージされ(手動での競合解決は不要)、編集作業を中断することなく、ドキュメントは常に一貫性を保ちます。

このガイドでは、Prosemirrorの共同編集機能の使い始め方を説明します。

アルゴリズム

Prosemirrorの共同編集システムは、central authority(中央認証)モデルを使用しています。これは、各ユーザーの変更がどの順序でドキュメントに適用されるかを決定します。2つのエディタが同時に変更を行った場合、これらの変更はauthorityに送信されます。authorityはそのうちの1つの変更を受け入れ、すべてのエディタにその変更をブロードキャストします。他の変更は受け入れられず、エディタがサーバーから新しい変更を受け取ると、ローカルの変更を他のエディタからの最新の変更にrebaseし、再度ローカルの変更を送信しようとします(このrebaseはgitのrebaseに似ており、ローカルの変更は保持されたまま(サーバーに拒否されたため)、以前のエディタドキュメントを最新の状態に更新し、再度ローカルの変更を送信します——サーバーが受け入れるかどうかを確認します)。

The Authority

中央権威(central authority)の役割は実に単純で、以下のことを行う必要があります:

  • 現在のドキュメントのバージョンを追跡する
  • 編集者からの変更を受け入れ、それらが適用された際に自身の変更リストに追加する
  • 編集者に対して、特定のバージョンを受け取る手段を提供する

JavaScript環境で動作する極小のcentral authorityを実装しましょう。これは編集者と同じ環境で実行されます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Authority {
constructor(doc) {
this.doc = doc;
this.steps = [];
this.stepClientIDs = [];
this.onNewSteps = [];
}
receiveSteps(version, steps, clientID) {
if (version != this.steps.length) return;
// Apply and accumulate new steps
steps.forEach((step) => {
this.doc = step.apply(this.doc).doc;
this.steps.push(step);
this.stepClientIDs.push(clientID);
});
// Signal listeners
this.onNewSteps.forEach(function (f) {
f();
});
}
stepsSince(version) {
return {
steps: this.steps.slice(version),
clientIDs: this.stepClientIDs.slice(version),
};
}
}

編集者が変更をauthorityに送信しようとする際、authorityのreceiveStepsメソッドを呼び出します。最後に受け取ったバージョン番号、そのバージョンに対して加えた新しい変更、そしてクライアントID(自身の変更を識別するためのもの)を渡します。

この送信がauthorityに受理されると、クライアントは通知を受け取ります。なぜならauthorityがサーバーからの新しい変更が利用可能であることを知らせ、それぞれの変更手順を提供するからです。実際のauthority実装では、receiveStepsが状態を返し、送信した変更手順を即座に確認する最適化も可能です(サーバーからの変更通知を待たずに)。しかし、信頼性の低いネットワーク環境に対応するため、サーバーからの変更通知を待つ仕組みを常にフォールバックとして保持すべきです。

このサンプルのauthority実装では、無限に成長するステップ配列を持ち、その長さが現在のバージョンを表します。

collabモジュール

collabモジュールはcollab関数をエクスポートします。この関数は、ローカルの変更を追跡し、リモートの変更を受け入れ、さらにどの変更をいつauthorityに送信すべきかを指示するプラグインを返します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { schema } from 'prosemirror-schema-basic';
import collab from 'prosemirror-collab';

function collabEditor(authority, place) {
let view = new EditorView(place, {
state: EditorState.create({
doc: authority.doc,
plugins: [collab.collab({ version: authority.steps.length })],
}),
dispatchTransaction(transaction) {
let newState = view.state.apply(transaction);
view.updateState(newState);
let sendable = collab.sendableSteps(newState);
if (sendable)
authority.receiveSteps(
sendable.version,
sendable.steps,
sendable.clientID
);
},
});

authority.onNewSteps.push(function () {
let newData = authority.stepsSince(collab.getVersion(view.state));
view.dispatch(
collab.receiveTransaction(view.state, newData.steps, newData.clientIDs)
);
});

return view;
}

collabEditor関数は、collabプラグインを読み込んだeditor viewを新規作成します。stateが更新されるたびに、authorityに送信すべき変更があるか確認し、あれば送信します。

また、新しい変更ステップが利用可能になった際にauthorityが呼び出す関数を登録します。この関数は、authorityが指示したステップに従ってローカルエディタを更新するトランザクションを作成します。

ステップセットがauthorityに拒否された場合、変更ステップは未確認状態のまま保持されます。その後、authorityから新しい変更ステップを受け取ると(新しい変更を受け取った後)、onNewStepsコールバックがdispatchを呼び出すため、dispatchTransaction関数がトリガーされ、再度変更の送信を試みます。

これがすべてです。もちろん、colab demoのような長いポーリングやWebSocketを使った非同期データフローの場合、より複雑な通信・同期コードが必要になります。メモリ消費を抑えるため、authorityがステップを破棄するケースもあるでしょう。しかし全体として、この小さなサンプルはauthorityの実装に必要な要素を網羅しています。

- EOF -
この記事の初出: 「訳」 ProseMirror 日本語ガイド - Xheldon Blog