Shift+Enter で挿入したソフト改行の選択行のみにブロック引用を適用する修正
ブロック引用コマンドがソフト改行(Shift+Enter)を含む段落全体を対象にしていたバグを修正しました。選択した視覚的行のみをパラグラフから分割して引用ブロックに変換することで、期待通りの部分的な引用が可能になります。
背景
Shift+Enter によるソフト改行は、エディタ上では複数行に見えていても、内部的には単一の段落ノード(ParagraphNode)の中に LineBreakNode を挟んだ構造として表現されます。Lexxy のブロック引用コマンドは段落単位でノードラッピングを行う toggleNodeWrappingAllSelectedNodes を呼び出していたため、選択範囲が中間行だけであっても段落全体がブロック引用に変換されてしまっていました。
たとえば First line<br>Second line<br>Third line という段落で「Second line」だけを選択してから引用ボタンを押すと、3行すべてがブロック引用になるという問題です。期待される動作は <p>First line</p><blockquote><p>Second line</p></blockquote><p>Third line</p> のように、選択行だけが独立した引用ブロックになることです。
技術的な変更
wrapSelectedSoftBreakLines メソッドを新設 し、引用コマンドの実行前にソフト改行を含む段落の部分選択ケースを検出・処理するロジックを追加しました。このメソッドが true を返した場合(=ソフト改行の部分選択が処理された場合)に限り、従来の toggleNodeWrappingAllSelectedNodes の呼び出しをスキップします。
src/editor/command_dispatcher.js の変更は次の通りです:
変更前:
dispatchInsertQuoteBlock() {
this.contents.toggleNodeWrappingAllSelectedNodes((node) => $isQuoteNode(node), () => $createQuoteNode())
}
変更後:
dispatchInsertQuoteBlock() {
if (!this.contents.wrapSelectedSoftBreakLines(() => $createQuoteNode())) {
this.contents.toggleNodeWrappingAllSelectedNodes((node) => $isQuoteNode(node), () => $createQuoteNode())
}
}
src/editor/contents.js に追加された wrapSelectedSoftBreakLines は、読み取りフェーズと更新フェーズの2段階で動作します。まず editor.getEditorState().read() の中で、選択がソフト改行を含む段落内の部分選択かどうかを判定し、対象の段落キーと選択行範囲(selectedLineRange)を取得します。段落全体が選択されている場合(start === 0 && end === lines.length - 1)はこのメソッドの処理対象外として false を返し、従来の全体ラッピング処理に委ねます。
wrapSelectedSoftBreakLines(newNodeFn) {
let paragraphKey = null
let selectedLineRange = null
this.editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection) || selection.isCollapsed()) return
const paragraph = this.#getSelectedParagraphWithSoftLineBreaks(selection)
if (!paragraph) return
const lines = this.#splitParagraphIntoLines(paragraph)
selectedLineRange = this.#getSelectedLineRange(lines, selection)
if (!selectedLineRange) return
const { start, end } = selectedLineRange
if (start === 0 && end === lines.length - 1) return
paragraphKey = paragraph.getKey()
})
if (!paragraphKey || !selectedLineRange) return false
this.editor.update(() => {
const paragraph = $getNodeByKey(paragraphKey)
if (!paragraph || !$isParagraphNode(paragraph)) return
const lines = this.#splitParagraphIntoLines(paragraph)
this.#replaceParagraphWithWrappedSelectedLines(paragraph, lines, selectedLineRange, newNodeFn)
})
return true
}
読み取りフェーズで条件を満たした場合のみ editor.update() を実行し、 #replaceParagraphWithWrappedSelectedLines によって段落を選択行・非選択行に分割し、選択行のみを引用ノードでラップして置換します。読み取りと更新を分離した2フェーズ設計は、Lexical の推奨パターンに沿ったものです。
設計判断
既存の toggleNodeWrappingAllSelectedNodes を置き換えず、前段のガード処理として新メソッドを追加する方式 が採用されました。ソフト改行を含む部分選択という特殊ケースだけを先に処理し、それ以外(選択範囲が複数段落にまたがる場合、段落全体が選択されている場合、ソフト改行を含まない段落の場合)はそのまま既存の汎用処理に流れます。これにより、既存の引用トグルロジックへの影響を最小限に抑えながら、バグを修正しています。
また、 #splitParagraphIntoLines と #getSelectedLineRange をプライベートメソッドとして抽象化しているため、同種のソフト改行対応が将来他のフォーマットコマンド(太字・斜体など)に必要になった際にも再利用できる構造になっています。
テストは test/browser/tests/toolbar.test.js に <p>First line<br>Second line<br>Third line</p> のセットアップで追加されており、引用対象の行分割後の HTML 構造を直接アサートすることでリグレッションを防ぎます。
まとめ
本PRは、LineBreakNode による「見た目上の改行」と「段落の区切り」の非対称性から生じるフォーマット操作のバグを、段落の事前分割によって解消した変更です。ガード処理として新メソッドを前段に挿入するアプローチにより、既存のノードラッピング処理の汎用性を損なわずに特殊ケースに対応した設計は、同種の問題への対処パターンとして参考になります。