空リストアイテム削除時のカーソルジャンプを修正
空のリストアイテムでBackspaceを押した際、Lexicalのデフォルト動作がカーソルをドキュメント上部に飛ばしてしまう問題を修正しました。DELETE_CHARACTER_COMMAND をインターセプトして空アイテムを削除し、隣接アイテムへカーソルを適切に移動させます。
背景
Lexical エディタには、リストアイテムの先頭でBackspaceを押した際に collapseAtStart を呼び出し、そのアイテムを段落(paragraph)へと変換するデフォルト挙動があります。この動作はリストの最後のアイテムを「リストから抜け出す」ために利用する場面では自然ですが、兄弟アイテムが存在する空アイテムに対して実行されると、段落がリスト全体の上方に挿入される形となり、カーソルがリスト外のドキュメント上部へジャンプするという問題を引き起こしていました。
具体的なシナリオは次の通りです。リストアイテムの先頭にカーソルを置いてEnterを押すと、空の新規アイテムが現在位置の上に挿入されます。その空アイテムにカーソルを移動してBackspaceを押すと、Lexicalは空アイテムをリストから外して段落へ変換し、カーソルがリスト外へ移動してしまいます。
技術的な変更
修正は selection.js と lexical_helper.js の2ファイルに分かれており、「空アイテムの判定」と「カーソル再配置」の2つの責務を分離しています。
lexical_helper.js に新たに追加された $isListItemStructurallyEmpty 関数は、リストアイテムが意味のあるコンテンツを含まないかどうかを判定します。getTextContent().trim() === "" のような単純な文字列比較ではなく、子ノードを走査してデコレーターノード(メンション・添付ファイルなど)が存在する場合は非空と判定します。これは、一部のデコレーターノードが \ufeff などの不可視文字を getTextContent() で返すため、文字列比較では誤って空と見なされる恐れがあるためです。
export function $isListItemStructurallyEmpty(listItem) {
const children = listItem.getChildren()
for (const child of children) {
if ($isDecoratorNode(child)) return false
if ($isLineBreakNode(child)) continue
if ($isTextNode(child)) {
if (child.getTextContent().trim() !== "") return false
} else if ($isElementNode(child)) {
if (child.getTextContent().trim() !== "") return false
}
}
return true
}
selection.js 側では、#selectDecoratorNodeBeforeDeletion メソッドの冒頭に #removeEmptyListItem の呼び出しを追加しています。このメソッドはBackspace操作の処理パイプライン上にあり、backwards フラグが true(Backspace)の場合のみ動作します。
#selectDecoratorNodeBeforeDeletion(backwards) {
if (backwards && this.#removeEmptyListItem()) return true
// ...
}
#removeEmptyListItem の本体では、セレクションが collapsed(カーソル状態)であること、カーソルが ListItemNode 内にあること、そのアイテムが構造的に空であることを順に確認します。次の兄弟(nextSibling)が存在しない場合は即座に false を返してLexicalのデフォルト処理へ委ねます。これにより、リストの最後のアイテムでのBackspaceは従来通りリスト終了として機能します。次の兄弟が存在する場合は、前の兄弟があれば previousSibling.selectEnd()、なければ nextSibling.selectStart() でカーソルを移動した後、空アイテムを削除します。
#removeEmptyListItem() {
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
const anchorNode = selection.anchor.getNode()
const listItem = $getNearestNodeOfType(anchorNode, ListItemNode)
if (!listItem) return false
if (!$isListItemStructurallyEmpty(listItem)) return false
const nextSibling = listItem.getNextSibling()
if (!nextSibling) return false
const previousSibling = listItem.getPreviousSibling()
if (previousSibling) {
previousSibling.selectEnd()
} else {
nextSibling.selectStart()
}
// ...
}
設計判断
「最後のアイテムは意図的にデフォルト動作へ委ねる」 という選択が、この修正の核心にある設計判断です。
リストの最後の空アイテムでのBackspaceは、「リストから抜け出す」という一般的なエディタの慣用操作です。この動作を上書きしてしまうと、キーボードだけでリストを終了する手段が失われます。nextSibling の存在を条件にすることで、兄弟アイテムを持つ「中間の空アイテム」の削除のみをインターセプトし、最後のアイテムのケースはLexicalのデフォルトへ明示的にフォールスルーする設計になっています。
また、空判定に専用の $isListItemStructurallyEmpty を用意した点も注目に値します。デコレーターノードを「非空コンテンツ」として扱うことで、メンションや添付ファイルを含むアイテムが誤って削除されるリスクを排除しています。
まとめ
Lexicalのデフォルト動作を全面的に置き換えるのではなく、「兄弟が存在する空アイテム」という特定条件下のみでインターセプトし、それ以外はデフォルトへ委ねる設計は、フレームワークの挙動との共存を重視した最小変更の修正です。デコレーターノードを考慮した構造的な空判定ロジックの切り出しにより、同様の判定が必要な将来の拡張にも再利用可能な形になっています。