Open6

ProseMirrorドキュメント読みメモ

pirosikickpirosikick

昔触っていたが、記憶喪失なので再度読む。備忘録としてメモを残す。

pirosikickpirosikick

ProseMirror Guide

Introduction

  • ProseMirrorの原則:コードがドキュメントとその処理を完全に制御できるようにする
  • ProseMirrorベースでエディタを配布することを期待しているので、単純さよりモジュール性・カスタマイズ性を重視
  • 4つの必須モジュール
    • prosemirror-model = エディタのドキュメントモデル、データ構造
    • prosemirror-state = エディタの状態全体を記述するデータ構造、次の状態へ遷移するためのトランザクションシステム
    • prosemirror-view = UI
    • prosemirror-transform = stateのトランザクションの基礎、履歴と共同編集など
  • バンドラーと一緒に使う想定

Transaction

// (Imports omitted)

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)
  }
})
  • ユーザーが入力 or ビュー操作で、"state transactions"を生成
  • トランザクションをstate.applyすると、次のstateができる。
  • 次のstateをview.updateStateで適応すると、Viewが更新される

Plugin

  • プラグイン機構だよ〜

Command

  • コマンド = 特殊な関数
  • ほとんどの編集アクションはコマンドで記述されており、キーにバインドしたり、メニューに連動させたりできる
  • prosemirror-commandsにベーシックなコマンド・キーマップがある(EnterキーやDeleteキーなど)
  • メニューや追加のキーマップを追加したいなら、prosemirror-example-setupが一般的なプラグインを提供しているから参考になる

Content

  • doc =
    • 文章を表現している読み取り専用のデータ構造
    • DOMみたいにノードが階層構造になっている
  • state初期化時に初期のドキュメントを与えることができる
pirosikickpirosikick

Documents

  • ProseMirrorは、文書(document)を表す独自のデータ構造を定義

Structure

  • documentはnode、nodeは0以上の子nodeを含むfragmentを保持
  • DOMと似ているが、インラインコンテンツの保存方法が異なる
<p>This is <strong>strong text with <em>emphasis</em></strong></p>

こういうHTMLがある場合に、DOMはツリーで表現する。

p
├ "This is"
└ strong
    ├ "strong text with "
    └ em
        └ "emphasis"

ProseMirrorはインラインコンテンツをフラットシーケンスに表現し、strongやemはnodeのメタデータとして扱う。

p
├ "This is"
├ "strong text with " with strong
└ "emphasis" with strong & em
  • この構造によって、ツリー内のパスではなく、文字オフセットで段落内の位置を表現可能
    • 厄介なツリー操作が不要になる
  • 同じドキュメントを表現する方法が1つしか無い
    • 同じマークアップの隣接するnodeは結合
    • 空のテキストノードは許可されない
  • ProseMirrorのdocument = block nodeの木
    • Leaf blockはほとんどtext block(文字を含むblock node)
    • 動画や水平線など、空のLeaf blockもOK
    • paragraph = text block
    • blockquote = 他のblockを含むblock要素

(ここらへんは具体例無いとわかりづらそう)

Identity and persistence

  • ProseMirrorのDocumentはDOMと違ってImmutableだよという話
    • ドキュメントを更新するたびに、新しいドキュメントを生成
    • 前のドキュメントで変更されなかった値は、新しいドキュメントでも共有

Data structures

ドキュメントのデータ構造は以下みたいな感じ

interface Node {
  type: NodeType;
  content: Fragment;
  attrs: Object;
  marks: Mark[];
}

type Fragment = Node[];

interface Mark {
  type: MarkType;
  attrs: Object;
}
  • 各ノードはNodeクラスのインスタンス
  • ノードの中身はFragmentのインスタンスに保持
    • ノードの中身がない場合でも空のFragmentがある
  • いくつかのNodeTypeでは、attrsが許可されている
    • 画像だったら、URLとかをattrsに持ったりする
  • インラインノードはemやstrong、linkみたいなmarksを保持
    • marksはMarkインスタンスの配列
  • 許可されるノードの種類はschemaで決定する

Indexing

  • 2種類のインデックス
    • ノードを木構造として扱う(子ノードをたどっていく)方法
    • トークンのフラットシーケンス。任意の位置を整数で表現
  • 後者についての規則
    • 最初のコンテンツの直前を0とする
    • リーフノードではないノードの出入りは1カウント(<p>で1つ、</p>で1つみたいなイメージ)
    • 空のノード(画像などコンテンツを持たないノード)も1カウント
<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>

上記のようなドキュメントの場合、各インデックスの位置は以下のようなイメージ(0が<p>の前で、一番最後が13)

0<p>1O2n3e4</p>5
5<blockquote>6<p>7T8w9o10<img>11</p>12</blockquote>13

  • node.content.size = ノードの中身のサイズ
  • node.nodeSize = the size of the entire node = ノード自身を含めたノードのサイズってこと?
  • ドキュメントのサイズはdoc.nodeSizeではなくdoc.content.size
    • docの外にカーソルを持っていくことはできないから
  • 位置を計算するのに、Node.resolveは便利

Slices

  • コピペ、D&Dを実装するにはドキュメントのSlice( = 2つの位置の間のコンテンツ)が必須
  • Sliceは、開始・終了のノードの一部が「開いている」という点で、完全なノード・Fragmentとは違う
    • ある段落の途中から次の段落の途中まで選択すると、aaaaaa</p><p>bbbbbbみたいな前後が「開いている」状態になるはず
    • Schemaの制約に違反する場合があるので、完全なNode/Fragmentでは表現できない
  • SliceはFragmentを開いた深さとともに保存
// <p>a</p><p>b</p>というドキュメントの場合

let slice1 = doc.slice(0, 3) // "<p>a</p>"
console.log(slice1.openStart, slice1.openEnd) // → 前後共に開いてないので0 0

let slice2 = doc.slice(1, 5) // "a</p><p>b"
console.log(slice2.openStart, slice2.openEnd) // → 前後共に1階層開いているので1 1

Changing

  • Node, Fragmentは直接変更しないこと
  • Transformationを使ってドキュメントを更新する
pirosikickpirosikick

Schemas

  • Documentに対するSchema
  • Schemaには、DocumentにあるNodeの種類とどのようにネストするかを記述

Node Types

  • すべてのNodeにはTypeがある
  • 下記はDocumentには1つ以上の段落、段落には任意数のテキストを含めることができる、というスキーマ
const trivialSchema = new Schema({
  nodes: {
    doc: {content: "paragraph+"},
    paragraph: {content: "text*"},
    text: {inline: true},
    /* ... and so on */
  }
})
  • 最低限、最上位のノードタイプ(デフォルトではdoc、変更可能)とtextが必要
  • インラインノードはinline: trueが必要

Content Expressions

  • contentには、content expressions(コンテンツ式)が入る
  • content expressionsの文法
    • "paragraph" = 1つの段落
    • "paragraph+" = 1つ以上の段落
    • "paragraph*" = 0つ以上の段落
    • "caption?" = 0 or 1つのcaption
    • "paragraph{3}" = 3つの段落
    • "paragraph{1,3}" = 1~3つの段落
    • "paragraph{2,}" = 2つ以上の段落
    • "heading paragraph+" = 1つの見出しと1つ以上の段落
    • "(paragraph | blockquote)+" = 1つ以上の段落 or blockquote
  • groupをつけると複数のNode Typeをひとまとめにできるグループを定義できる
    • グループ名をcontent expressions内で利用可能
    • 以下のコードでは、"block+""(paragraph | blockquote)+"は同義
const groupSchema = new Schema({
  nodes: {
    doc: {content: "block+"},
    paragraph: {group: "block", content: "text*"},
    blockquote: {group: "block", content: "block+"},
    text: {}
  }
})
  • 上記のコードでいうdocblockquoteのように、block nodeをcontentに持つNodeは最低1つ子Nodeを持つようにSchemaを組んだほうがいい
    • 空のNodeはブラウザ上で完全に畳まれてしまって、編集が難しくなってしまうため
  • コンテンツ式のOR内、グループ内のノードの順序は重要
    • 例えば、上記例のparagraphとblockquoteの順番を入れ替えると、スタックオーバーフローが発生する
    • 理由は、オプショナルではないNodeのデフォルトインスタンスを作る場合、それがグループだったら先に定義されているNodeが使われるため、blockquoteの中身のデフォルトインスタンスを作る→block+なので、blockquote→...みたいに無限ループになってしまう
  • すべてのノード操作関数が有効なコンテンツを処理しているか確認しているわけではない
    • Transformationなどの高レベルの操作ではやっているが、NodeType.createとかではやってないので、呼び出し側が正しさを担保する必要がある
    • checkメソッドなどを使って、正しさを検証できる

Marks

  • Marks = インラインコンテンツにスタイルやその他の情報を追加するために使用
  • Node同様、MarkにもTypeがある
  • デフォルトでは、インラインコンテンツを持つノードはSchemaに定義されているすべてのMarkを子に適用できる
  • marksプロパティで適用できるMarkを設定できる
    • スペース区切りでマーク名・マークのグループ名を指定
    • "_"はワイルドカード
    • 下記の例はparagraphは全部のMarkを、headingは利用できるMarkなしの状態
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: {}
  }
})

Attributes

  • Schemaでは各Nodeが持つAttributesも定義する
    • 例: headingのlevelなど
  // 例
  heading: {
    content: "text*",
    attrs: {level: {default: 1}}
  }
  • デフォルト値の指定(default)が無い、かつ、ノード作成時にその属性を指定しない場合はエラーになる

Serialization and Parsing

  • ProseMirrorのDocumentをブラウザのDOMで表現する方法について
  • toDOMフィールド
    • Nodeを引数に取る
    • 返り値は直接DOMノードを返すか、配列を返す
    • 配列の例
      • ["p", 0] = p要素でレンダー
      • ["div", { className: "c" }, 0] = class属性を付けたdiv要素でレンダー
  • MarkでもtoDOMフィールドを指定可能。配列で返す場合、0は不要
const schema = new Schema({
  nodes: {
    doc: {content: "paragraph+"},
    paragraph: {
      content: "text*",
      toDOM(node) { return ["p", 0] }
    },
    text: {}
  }
})
  • 逆に、ユーザーがエディタにペースト・ドラッグした場合、DOMからProseMirrorのDocumentにパースする必要がある
  • prosemirror-modelにそのための機能がある
  • OR SchemaにparseDOMプロパティを定義
  parseDOM: [
    {tag: "em"},                 // Match <em> nodes
    {tag: "i"},                  // and <i> nodes
    {style: "font-style=italic"} // and inline 'font-style: italic'
  ]
  • SchemaにparseDOMプロパティがある場合、DOMParser.fromSchemaでDOMParserを作成できる
    • デフォルトでクリップボードパーサーの作成で利用されるが、その挙動をオーバーライドすることもできる

Extending a schema

  • Schemaのコンストラクタに渡すnodesmarksはJSオブジェクト、または、OrderedMapを渡すことができる
  • spec.nodes, spec.marksは常にOrderedMap
  • OrderedMapは編集用の便利なメソッドが生えている
  • schema-listモジュールにも、nodeを足したりできる便利な関数がある
pirosikickpirosikick

Document transformations

  • Transformsは、ProseMirrorの動作の中心。トランザクションの基礎の形成、履歴の追跡、共同編集を可能にする

Why?

  • なぜミュータブルな変更を採用してないのかの話
  • Immutableは色んな面でいいぞ、という話

Steps

  • Documentの更新はステップに分解される。
    • 直接操作する必要はないが、仕組みを覚えておくと便利らしい
    • ReplaceStep, AddMarkStepなど
  • Schemaの制約を保持するなどはやってないので、Stepの適用が失敗することもある
  • ヘルパー関数がステップを生成するので、基本的には詳細を気にする必要はない

Transforms

  • 編集アクションは1つ以上のステップを生成する場合がある
  • 一連のステップを操作する最も便利な方法が、Transformオブジェクトの作成
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
  • チェインして呼び出せる(tr.delete(5, 7).split(5)みたいな)

Mapping

  • ドキュメント変更時にドキュメントを指す位置が変わる or 無効になる場合がある
  • ステップには、ステップ適用前後のドキュメント位置の変換を行うMapを提供している
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 (nothing changes before the change)
  • Transformオブジェクトには一連のステップを集めて、一度にマップするmappingを提供している
let tr = new Transform(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
  • ↑の最後の行は挿入されたコンテンツの前後どちらを指すのか不明確。上記の例では挿入されたトークン(splitで挿入される)の後に移動している
  • mapの第2引数に-1を設定すると、挿入前の位置になる
console.log(tr.mapping.map(10, -1)) // → 7
ちょっと分かりづらいので補足
<p>1234567890</p>

上記のドキュメントにサンプルのTransformを適用する。

let tr = new Transform(myDoc)
tr.split(10)    // split a node, +2 tokens at 10
tr.delete(2, 5) // -3 tokens at 2

そうすると、こうなるはず。

tr.split(10)
= <p>123456789</p><p>0</p>

tr.delete(2, 5)
= <p>156789</p><p>0</p>

変更前のインデックス10は、9と0の間を指している。

<p>123456789(ここが10)0</p>

最後の行(tr.mapping.map(10))は挿入されたタグの後の位置を指しているので、以下の位置(9)になる。

<p>156789</p><p>(ここ!ここが9)0</p>

一方、tr.mapping.map(10, -1)は、挿入されたタグの前なので、以下の位置(7)になる。

<p>156789(ここ!ここが7)</p><p>0</p>

Rebasing

  • ステップをリベースする必要が生じる場合がある
    • 独自の変更追跡、協調編集
    • ステップと位置マップを使用して複雑なことを行う場合
  • リベース = 同じDocumentから開始する2つのステップを実行、一方を変換して、代わりにもう一方が作成したドキュメントに適用できるようにするプロセス
    • 操作変換のことかも
    • 操作変換よりも複雑な感じっぽいな
stepA(doc) = docA
stepB(doc) = docB
stepB(docA) = MISMATCH!
rebase(stepB, mapA) = stepB'
stepB'(docA) = docAB
  • 一連のステップが2つあってリベースする場合は、より複雑になってしまう↓
stepA2(stepA1(doc)) = docA
stepB2(stepB1(doc)) = docB
???(docA) = docAB
  • stepB1をstepA1, stepA2からマップしたstepB1`を得ることができる。
  • が、stepB2はstepB1(doc)をベースに開始している、かつ、これをstepB1`(docA)に適用できるようにしないといけないので、かなり複雑になる。
  • で、リベースは以下のようになる
rebase(stepB2, [invert(mapB1), mapA1, mapA2, mapB1']) = stepB2`
  • invert(mapB1) = 元のドキュメントを得るため。その後は各StepのMapのPipelineを適用する
  • MappingがこのようなMapのPipelineを追跡する方法を提供しているらしい
pirosikickpirosikick

The editor state

  • Stateを構成するもの
    • Document
    • current selection
      • 現状の選択範囲
    • 現在のマークのセット
      • 入力開始前は保存するノードが存在しないから?
  • 各プラグインの状態を保存するために、追加のスロットを定義できる

Selection

  • ProseMirrorは、いくつかのタイプの選択をサポート。または、3rd party製の選択タイプも定義できる
  • Selectionクラスのインスタンス。Immutable
  • 少なくとも開始位置 = .from, 終了位置 = .toの2つ、アンカー側(移動不可)とヘッド側(移動可能)を区別するためのプロパティがある
  • 最も一般的なのは、テキスト選択
    • アンカーとヘッドが同じ位置の場合は通常のカーソル
    • 開始・終了位置の両方がインライン位置にある必要がある
  • ノード選択
    • Ctrl/Cmdを押しながらクリックで、単一のノードを選択

Transactions

  • Stateの更新はtransactionを今のStateに適用し、新しいStateを生成することで起こる
  • TransactionはTransformのサブクラスで、ステップを適用して新しいドキュメントを生成する方法を継承している
  • それに加えて、Selectionや他の状態関連のコンポーネントを追跡、replaceSelectionみたいな便利関数を提供