📖

Tiptapコードリーディング:bullet-list, ordered-list (1)

に公開

前回
https://zenn.dev/karintou/articles/a78693743ee765

今回はリスト編です。流れ的には Heading ですが、興味ある方を優先的に読んでいきます。

リストは順序なし(bullet-list)順序あり(ordered-list)の2種類あり、子要素にどちらもlist-item, <li>を持ちます。

今回はこれら3つのノードを確認していきます。
調べていくと複雑で力尽きたので、本記事は list-item だけにします。。

list-item

https://tiptap.dev/docs/editor/extensions/nodes/list-item

https://github.com/ueberdosis/tiptap/blob/main/packages/extension-list/src/item/list-item.ts

まずは 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内でした。
https://github.com/ProseMirror/prosemirror-transform/tree/master/src/replace.ts#L334C17-L403

具体例ないかなぁ。。

addKeyboardShortcuts

addKeyboardShortcutsではいくつかショートカットキーを追加しています。

Enter

選択位置でリストを分割して、次の行に新たなリストアイテムが作成されます。
NotionとかSlackなど、普段から使っているのでイメージ湧きやすいですね。
コードではcommands.splitListItemが呼ばれています。
この処理はTiptapで定義されていて、そこそこ複雑です。

https://github.com/ueberdosis/tiptap/blob/main/packages/core/src/commands/splitListItem.ts

ガード処理
前半で選択範囲がlist内であることを保証しています。

変換処理は以下の3パターンです

  • 空行以外
  • 「ネスト && 末尾 list 」を除く箇所の空行
  • 「ネスト && 末尾 list 」の空行

「ネスト && 末尾list」の箇所とは以下のような場所を指します。

<ul>
  <li>
    <p>list</p>
    <ul>
      <li>nest-list</li>
      <li>[ここ]<li>
    </ul>
  </li>
</ul>

変換処理(通常)

  1. 選択範囲を削除 tr.delete($from.pos, $to.pos)
  2. 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が実行されます。
https://github.com/ProseMirror/prosemirror-commands/blob/20c7d42ab8b5d8642fb9efc6261b7541c9dc23c2/src/commands.ts#L339-L353

「ネスト && 末尾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の処理

https://github.com/ProseMirror/prosemirror-schema-list/blob/e65a144f2285178a20cbdea5b0ee16925be7d6c5/src/schema-list.ts#L245-L267

この処理がよばれる条件は

  • 選択範囲が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の処理
(力尽きたので、後ほど追記するかもです。。)

https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts#L186-L197

$from.node(range.depth - 1).type == itemTypeで、選択範囲のulがネストされているか確認しています。

ネストされていた場合

tr.lift(range, target)でリフトする

https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts#L199-L215

トップレベルの場合

選択範囲をいい感じにしながら、listの外に出す

https://github.com/ProseMirror/prosemirror-schema-list/blob/e65a144f2285178a20cbdea5b0ee16925be7d6c5/src/schema-list.ts#L217-L241

最後に

TiptapがProseMirror側の処理を流用していることが大半だったので、TiptapというよりかはProseMirrorのリーディングでした。
ただ、ProseMirrorの内部処理の理解が進んでためになった気がします。

読んでばかりなので、コードも書いていきたい。

Discussion