インラインコード適用時のフラグメント化バグを修正
混在するインラインフォーマット(太字・斜体など)を含むテキストにコードフォーマットを適用すると、フォーマットごとに <code> 要素が分割されてしまう問題を修正しました。コード適用前に選択範囲のインラインフォーマットを除去することで、常に単一の <code> 要素を生成します。
背景
Lexicalエディタでは、異なるフォーマットを持つテキストノードは別々のノードとして管理されます。このため、「通常テキスト」「太字」「斜体」が混在する選択範囲に FORMAT_TEXT_COMMAND でコードフォーマットを一括適用すると、Lexicalがノードを結合できず、フォーマットの数だけ <code> 要素が生成されていました。
たとえば Hello <strong>bold</strong> and <em>italic</em> world 全体を選択してコードボタンを押すと、期待される <code>Hello bold and italic world</code> ではなく、<code>Hello </code><code>bold</code><code> and </code><code>italic</code><code> world</code> のように断片化した出力が生成されていました。
この問題の本質は、Lexicalが FORMAT_TEXT_COMMAND を適用する際、既存フォーマットを持つノードをそのまま保持するため、フォーマットが異なるノード同士が結合されないという仕様にあります。
技術的な変更
dispatchInsertCodeBlock() が FORMAT_TEXT_COMMAND を直接呼び出す代わりに、新たに追加した #toggleInlineCode() プライベートメソッドを呼び出すよう変更されました。このメソッドがコード適用前の前処理を担います。
変更前:
dispatchInsertCodeBlock() {
if (this.selection.hasSelectedWordsInSingleLine) {
this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code")
} else {
this.contents.toggleCodeBlock()
}
}
変更後:
dispatchInsertCodeBlock() {
if (this.selection.hasSelectedWordsInSingleLine) {
this.#toggleInlineCode()
} else {
this.contents.toggleCodeBlock()
}
}
#toggleInlineCode() {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
if (!selection.isCollapsed()) {
const textNodes = selection.getNodes().filter($isTextNode)
const applyingCode = !textNodes.every((node) => node.hasFormat("code"))
if (applyingCode) {
this.#stripInlineFormattingFromSelection(selection, textNodes)
}
}
this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code")
}
フォーマット除去の実装は #stripInlineFormattingFromSelection() が担います。このメソッドは、選択範囲が境界ノードの途中から始まる・終わるケースを正しく処理します。具体的には、選択範囲が境界ノードの途中にある場合に node.splitText(startOffset, endOffset) を呼び出してノードを分割し、選択範囲内の部分ノードのみに setFormat(0) を適用します。選択外のテキスト(例: <strong>bold text</strong> のうち選択されていない末尾の一部)は元のフォーマットを保持します。
#stripInlineFormattingFromSelection(selection, textNodes) {
const isBackward = selection.isBackward()
const startPoint = isBackward ? selection.focus : selection.anchor
const endPoint = isBackward ? selection.anchor : selection.focus
for (let i = 0; i < textNodes.length; i++) {
const node = textNodes[i]
if (node.getFormat() === 0) continue
const isFirst = i === 0
const isLast = i === textNodes.length - 1
const startOffset = isFirst && startPoint.type === "text" ? startPoint.offset : 0
const endOffset = isLast && endPoint.type === "text" ? endPoint.offset : node.getTextContentSize()
if (startOffset === 0 && endOffset === node.getTextContentSize()) {
node.setFormat(0)
} else {
const splits = node.splitText(startOffset, endOffset)
const target = startOffset === 0 ? splits[0] : splits[1]
target.setFormat(0)
}
}
}
また、#toggleInlineCode() 内では「コードを適用しようとしているか(applyingCode)」の判定も行っています。選択中のすべてのノードが既にコードフォーマットを持つ場合(つまりトグルオフ操作)は、フォーマット除去をスキップして直接 FORMAT_TEXT_COMMAND に委譲します。
設計判断
Lexicalの FORMAT_TEXT_COMMAND をそのまま維持し、その手前に前処理を挿入する構成 が採用されました。Lexicalの内部ノード管理を直接操作するのではなく、コマンドディスパッチ前にノードの状態を正規化するアプローチです。これにより、コードフォーマットの適用・解除といったコアロジックはLexicalに委ねたまま、フラグメント化の問題のみをアプリケーション層で解決しています。
部分選択への対応に splitText() を活用している点も注目されます。選択境界がノードの途中にある場合、ノードを分割してから選択範囲内のノードにのみ setFormat(0) を適用することで、選択外テキストのフォーマットが損なわれません。テストケースでは Hello <strong>bold text</strong> and <em>italic text</em> world から「bold text and italic」を部分選択してコードを適用し、Hello <code>bold text and italic</code><em> text</em> world が得られることを検証しています。
この設計は、Lexicalのノードモデルを深く変更することなく、最小限のコード追加でバグを修正しています。CommandDispatcher クラスに閉じた実装であり、影響範囲が限定的です。
まとめ
この修正は、Lexicalのノードモデルに起因するフォーマットフラグメント化の問題を、コマンド実行前の前処理として解決した変更です。splitText() を用いた境界処理によって部分選択のケースにも対応しており、選択外フォーマットの保持と選択内の単一 <code> 要素生成を両立しています。