Tiptapコードリーディング:bullet-list, ordered-list (1)
前回
今回はリスト編です。流れ的には Heading ですが、興味ある方を優先的に読んでいきます。
リストは順序なし(bullet-list)
と順序あり(ordered-list)
の2種類あり、子要素にどちらもlist-item, <li>
を持ちます。
今回はこれら3つのノードを確認していきます。
調べていくと複雑で力尽きたので、本記事は list-item だけにします。。
list-item
まずは list-item からです。tagは<li>
ですね。
content: 'paragraph block*',
の意味がまず気になります。
ぱっと見るとorっぽいですが、実は1つ目が paragraph で、2つ目以降は block 要素を受け入れるという意味です。
block* のところには、例えばリストをネストする時とかに使います。Heading とかも挿入可能です。
defining
他に, defining
というプロパティがあります。
ドキュメントには、enables both definingAsContext and definingForContent.
と書いてあったのですが、それぞれのプロパティの説明を読んでも良く分かりませんでした。。。
definingAsContext?: boolean
このノードが、置換操作(ペーストなど)の際に重要な親ノードと見なされるかどうかを決定します。非定義(デフォルト)ノードは、その内容全体が置換されると削除されますが、定義ノードは存続し、挿入されたコンテンツを囲みます。
definingForContent?: boolean
挿入されたコンテンツでは、コンテンツの定義元である親が可能な限り保持されます。通常、デフォルトではない段落のテキストブロックタイプ、およびおそらくリストアイテムは、定義元としてマークされます。
このプロパティが使われる箇所をコードから調べると、ともにreplaceRange
内でした。
具体例ないかなぁ。。
addKeyboardShortcuts
addKeyboardShortcutsではいくつかショートカットキーを追加しています。
Enter
選択位置でリストを分割して、次の行に新たなリストアイテムが作成されます。
NotionとかSlackなど、普段から使っているのでイメージ湧きやすいですね。
コードではcommands.splitListItem
が呼ばれています。
この処理はTiptapで定義されていて、そこそこ複雑です。
ガード処理
前半で選択範囲がlist内であることを保証しています。
変換処理は以下の3パターンです
- 空行以外
- 「ネスト && 末尾 list 」を除く箇所の空行
- 「ネスト && 末尾 list 」の空行
「ネスト && 末尾list」の箇所とは以下のような場所を指します。
<ul>
<li>
<p>list</p>
<ul>
<li>nest-list</li>
<li>[ここ]<li>
</ul>
</li>
</ul>
変換処理(通常)
- 選択範囲を削除
tr.delete($from.pos, $to.pos)
-
tr.split($from.pos, 2)
で分割
このtr.splitの第二引数は、現在のdepthから引き算します(デフォルトで1)
つまりtext => paragraph => list-item
なので、list-itemの深さでsplitをするという意味です。
他は属性やマーク関連の処理です。
「ネスト && 末尾 list 」を除く箇所の空行
挙動として、list が lift して paragraph になります。
<ul>
<li><p>list</p></li>
<li><p>[Enter]</p></li>
</ul>
⬇️
<ul>
<li><p>list</p></li>
</ul>
<p></p>
<ul>
<li><p>list</p></li>
<li><p>[Enter]</p></li>
<li><p>list3</p></li>
</ul>
⬇️
<ul>
<li><p>list</p></li>
</ul>
<p></p>
<ul>
<li><p>list3</p></li>
</ul>
<ul>
<li>
<p>list</p>
<ul>
<li><p>[Enter]</p></li>
<li><p>nest-list</p></li>
</ul>
</li>
<li><p>list3</p></li>
</ul>
⬇️
<ul>
<li>
<p>list</p>
<p></p>
<ul>
<li><p>nest-list</p></li>
</ul>
</li>
<li><p>list3</p></li>
</ul>
この挙動は、50行目のコードで弾かれることで起こります。
if ($from.depth === 2 || $from.node(-3).type !== type || $from.index(-2) !== $from.node(-2).childCount - 1) {
return false
}
弾かれることで、デフォルトでEnterにマッピングされている、liftEmptyBlockが実行されます。
「ネスト && 末尾list」の空行
55行目の処理、複雑なことをやっています。
やりたいことは、list を1段階 lift することです。
そのための例として、次の構造を考えます
<ul>
<li>
<p>list</p>
<ul>
<li><p>nest-list</p></li>
<li><p>[Enter]</p></li>
</ul>
</li>
</ul>
⬇️
<ul>
<li>
<p>list</p>
<ul>
<li><p>nest-list</p></li>
</ul>
</li>
<li><p></p></li>
</ul>
この挙動を実現するためには、replaceで以下のSliceを挿入します。
</ul></li>
<li><p></p></li>
openStart=2, openEnd=0
前が開いている構造です。Enterを押した<li>全体をこれで置換処理すると、nest-listを残しつつ、ulとliが閉じて上の階層で新たなliを作成します。
実装はこれを更に条件分岐して、一見何やってるか不明。う〜ん、複雑。。
Tab
sinkListItemが実行される。これはProseMirrorの処理
この処理がよばれる条件は
- 選択範囲がliである
- 選択範囲開始位置のliが先頭ではない (startIndexが0より大きい)
変換処理はここです。
if (dispatch) {
let nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type == parent.type
let inner = Fragment.from(nestedBefore ? itemType.create() : null)
let slice = new Slice(Fragment.from(itemType.create(null, Fragment.from(parent.type.create(null, inner)))),
nestedBefore ? 3 : 1, 0)
let before = range.start, after = range.end
dispatch(state.tr.step(new ReplaceAroundStep(before - (nestedBefore ? 3 : 1), after,
before, after, slice, 1, true))
.scrollIntoView())
}
変換は直前がネストリストであるかによって変わります。
単純な場合
let slice = new Slice(Fragment.from(itemType.create(null, Fragment.from(parent.type.create(null, inner)))), nestedBefore ? 3 : 1, 0)
ここを紐解くと、<ul></ul></li>
というSliceです。前がネストであるかどうかで、openStartの深さが異なります。
ReplaceAroundStepはwrapのような処理です。
StepはProseMirrorで変換の最小単位で、Node・Fragment・Sliceの操作を直接することが許されています。
new ReplaceAroundStep(
from: number,
to: number,
gapFrom: number,
gapTo: number,
slice: Slice,
insert: number,
structure?: boolean = false
)
(from, to)をsliceで置き換えて、sliceのinsert番目のところに(gapFrom, gapTo)を挿入します。
なので、ネストじゃない場合は(before - 1, after)をsliceで置き換えて、ulの中身に選択範囲のリストを入れるという意味です。
(before, after)は選択中の<li></li>部分で、before-1だと1つ上の</li>の直前です。
なので
<li>
<p>test</p>
<ul>...</ul> <= 挿入
</li>
という技ができますね。
sliceのポジション1は<ul>[ここ]</ul></li>
なので、listを上手に挿入できていることわかります。
直前がネストリストの場合
ネストだと直前が</ul>になってるので、-3します。
sliceの構造は</li></ul></li>
で、あとは単純な場合と同じように考えます。
<ul>
<li>
<p>list</p>
<ul>
<li><p>nest-list</p>[ここが-3]</li>
</ul>
</li>
<li><p></p></li>
</ul>
Shift + Tab
liftListItemが実行される。これもProseMirrorの処理
(力尽きたので、後ほど追記するかもです。。)
$from.node(range.depth - 1).type == itemType
で、選択範囲のulがネストされているか確認しています。
ネストされていた場合
tr.lift(range, target)
でリフトする
トップレベルの場合
選択範囲をいい感じにしながら、listの外に出す
最後に
TiptapがProseMirror側の処理を流用していることが大半だったので、TiptapというよりかはProseMirrorのリーディングでした。
ただ、ProseMirrorの内部処理の理解が進んでためになった気がします。
読んでばかりなので、コードも書いていきたい。
Discussion