コードブロックで複数のハイライトが共存できなかったバグを修正
Lexxyのコードブロックにおいて、2つ目以降のハイライト適用が1つ目を上書きしてしまう不具合が修正されました。原因はリトークナイザーが生成する TextNode を複数の関数が考慮していなかったことにあり、3箇所の修正によって複数ハイライトの共存が可能になりました。
背景
コードブロックへの連続したハイライト適用が正常に機能しない問題が発生していました。1つ目のハイライトを「hello」に適用した後、2つ目を「goodbye」に適用しようとすると、1つ目のハイライトが失われるという挙動です。
根本的な原因は、ハイライト適用後にリトークナイザーが走るタイミングにあります。$patchStyleText が内部で splitText を呼び出すと、CodeHighlightNode ではなく生の TextNode が一時的に CodeNode の子として存在する状態になります。その状態で2つ目のハイライト適用が始まると、TextNode を認識できない処理が誤った判断を下し、コードブロック専用のスタイリングパスをバイパスしてしまっていました。
技術的な変更
今回の修正は src/extensions/highlight_extension.js の3箇所に集中しています。それぞれが独立した役割を持ち、組み合わせることで問題を解消します。
1. $buildChildRanges の修正: 文字オフセット計算に TextNode を含めるよう拡張しました。
変更前:
for (const child of codeNode.getChildren()) {
if ($isCodeHighlightNode(child)) {
const text = child.getTextContent()
childRanges.push({ node: child, start: charOffset, end: charOffset + text.length })
charOffset += text.length
}
}
変更後:
for (const child of codeNode.getChildren()) {
if ($isCodeHighlightNode(child) || $isTextNode(child)) {
const text = child.getTextContent()
childRanges.push({ node: child, start: charOffset, end: charOffset + text.length })
charOffset += text.length
}
}
これにより、TextNode が混在していても各ノードの文字オフセットが正しく計算されます。保存済みのハイライト範囲との整合性を維持するためにも、TextNode をカウントに含めることが必要でした。
2. $applyHighlightRangesToCodeNode の修正: スタイル適用のループで TextNode をスキップするガード節を追加しました。TextNode は splitText で生成された一時的な存在であり、スタイル置換の対象は CodeHighlightNode のみです。文字オフセットの計算には参加させつつ、実際のスタイル書き換えからは除外することで、位置計算の正確さとスタイル適用の安全性を両立しています。
// Skip plain TextNodes: only CodeHighlightNodes can be split into
// styled replacements here.
if (!$isCodeHighlightNode(node)) continue
3. $extractHighlightRangesFromCodeNode の新規追加: ライブな CodeHighlightNode のツリーからハイライト範囲を読み出す関数が追加されました。HTMLインポート時に使われていた extractHighlightRanges(DOM要素から読む)に対応する、Lexicalノードツリーから読む版です。ハイライト適用後にこの関数で範囲を保存することで、リトークナイザーが走った後も既存のハイライトが失われず、保留ハイライト機構(HTMLインポートで既に使われていたもの)を通じて再適用されます。
function $extractHighlightRangesFromCodeNode(codeNode) {
const ranges = []
const childRanges = $buildChildRanges(codeNode)
for (const { node, start, end } of childRanges) {
const style = node.getStyle()
if (style && hasHighlightStyles(style)) {
ranges.push({ start, end, style })
}
}
return ranges
}
また、$isTextNode が lexical パッケージからインポートされるようになっており、型チェックの一貫性も確保されています。
設計判断
既存の保留ハイライト機構を再利用するアプローチが採られました。HTMLインポート時にコードブロックのハイライトをリトークナイザー後に再適用するために使われていた pending-highlights 機構が、インタラクティブな操作時にも同じ問題を抱えていたことになります。新たな状態管理を導入せず、既存のメカニズムを活用したことで、コードの一貫性と変更の局所性が保たれています。
$buildChildRanges が TextNode を含めつつ、$applyHighlightRangesToCodeNode ではスキップするという分離も意図的な設計です。文字オフセットの整合性を保つためには全ノードをカウントする必要がある一方、スタイル書き換えは CodeHighlightNode だけに限定したい。この2つの要求を関心の分離で解決しています。
回帰テストとして test/browser/tests/formatting/bug_code_block_multiple_highlights.test.js が追加されており、「hello」と「goodbye」への順次ハイライト適用後に両方の <mark> 要素が存在することをPlaywrightで検証します。リトークナイザーの非同期サイクルを考慮し、toPass でポーリングしている点も実装の複雑さを反映しています。
まとめ
本PRは、splitText が生む一時的な TextNode の存在を複数の処理関数が見落としていたという根本原因を特定し、文字オフセット計算・スタイル適用・ハイライト永続化の3層を整合的に修正することで解決しました。既存のHTMLインポート用機構を転用してハイライト範囲を保存・再適用する設計は、リトークナイザーが引き起こす中間状態の問題に対する汎用的なパターンとして機能しています。