Lexicalのカーソル制御を改善するProvisionalParagraphの導入
Lexical(Lexxy)でDecoratorNodeとエディタ境界の間のカーソル選択を制御する ProvisionalParagraphNode が導入されました。これは出力なしにカーソルのターゲットとなる暫定的な段落ノードで、画像ギャラリーなどのDecoratorNode周辺での選択体験を向上させます。
背景
Lexicalは以前、DecoratorNodeとエディタ境界の間での選択を処理する際に偽のカーソルを表示していました。この動作は見た目が不自然で、特に画像ギャラリー(#716で導入予定)で最適とは言えない振る舞いをしていました。
DecoratorNodeや非テキストのElementNodeは通常のテキスト選択をサポートしないため、これらのノードがエディタの端やノード同士の隣接部に配置された場合、カーソルの配置場所が曖昧になる問題がありました。この問題は空のエディタや、ノード間に<p>タグを挿入して流動的な選択を確保するといった複数のエッジケースを引き起こしていました。
技術的な変更
ProvisionalParagraphNode は ParagraphNode を拡張したノードで、以下の特徴を持ちます:
export class ProvisionalParagraphNode extends ParagraphNode {
static neededBetween(nodeBefore, nodeAfter) {
return !$isSelectableElement(nodeBefore, "next")
&& !$isSelectableElement(nodeAfter, "previous")
}
getTextContent() {
return ""
}
exportDOM() {
return {
element: null
}
}
}
このノードは getTextContent() が空文字を返し、exportDOM() で null を返すことで、HTML出力に影響を与えません。選択状態に応じてCSSクラス hidden が切り替わり、選択されていない時は最小限の高さ(0.5ch)で表示されます:
p.provisional-paragraph {
display: block;
&.hidden {
block-size: 0.5ch;
margin: 0;
}
}
ProvisionalParagraphは以下のタイミングで自動的に挿入されます:
- DecoratorNodeまたは非テキストElementNodeがエディタの上下端または互いに隣接している場合
- エディタが空の場合
ProvisionalParagraphExtension が RootNode の変更を監視し、$insertRequiredProvisionalParagraphs で必要な箇所にノードを挿入します:
function $insertRequiredProvisionalParagraphs(rootNode) {
const firstNode = rootNode.getFirstChild()
if (ProvisionalParagraphNode.neededBetween(null, firstNode)) {
$insertFirst(rootNode, new ProvisionalParagraphNode)
}
for (const node of $firstToLastIterator(rootNode)) {
const nextNode = node.getNextSibling()
if (ProvisionalParagraphNode.neededBetween(node, nextNode)) {
node.insertAfter(new ProvisionalParagraphNode)
}
}
}
ユーザーがProvisionalParagraphで編集を開始すると、通常の ParagraphNode に変換されます。また、不要になったProvisionalParagraphは自動的に削除されます。
設計判断
Lexicalの新しい static transform API を活用してトランスフォーム処理をノード自身に定義する方式が採用されました。
従来は外部でノードの変換処理を管理していましたが、$config() メソッド内で $transform を定義することで、ノードのロジックを一箇所に集約できます:
$config() {
return this.config("provisonal_paragraph", {
extends: ParagraphNode,
importDOM: () => null,
$transform: (node) => {
node.concretizeIfEdited(node)
node.removeUnlessRequired(node)
}
})
}
この設計により、編集時の具体化(concretizeIfEdited)と不要時の削除(removeUnlessRequired)をノードクラス内で完結させています。コードの可読性と保守性が向上する一方、ノードの責任範囲が広がるトレードオフがあります。
また、deleteSelectedNodes() メソッドが src/editor/contents.js から削除され、選択されたノードの削除処理が簡素化されました。ProvisionalParagraphの自動管理により、従来のように明示的にノードを削除して選択を調整する必要がなくなっています。
まとめ
本PRは、Lexicalのカーソル制御における長年の課題を解決する基盤を提供します。ProvisionalParagraphNodeの導入により、DecoratorNode周辺の選択が自然になり、偽のカーソルを表示する回避策が不要になりました。エディタの空状態処理やノード間への<p>挿入といったエッジケースも整理され、画像ギャラリーなどの複雑なコンポーネントの実装が容易になります。