プレーンテキストのコードブロック内でテキストハイライトを有効化
Lexicalの CodeHighlightNode が書式設定をブロックするという制約を回避し、プレーンテキストのコードブロック内でも色・背景色のハイライトを適用できるようにしました。
背景
Lexicalの CodeHighlightNode は、コードブロック内のノードを管理するために用いられますが、canHaveFormat() → false を返し、setFormat() を呼び出しても何もしないという制約があります。この仕様により、通常の $patchStyleText() によるスタイル適用がコードブロック内では機能せず、ハイライトのトグル操作がサイレントに無視されていました。
加えて、コードブロックにはシンタックスハイライトのトランスフォームが登録されており、スタイルを適用した後に別の更新サイクルが走るとそのトランスフォームが再実行され、せっかく適用したスタイルが上書きされてしまうという二重の問題がありました。
これらの制約は通常のリッチテキスト向けの設計に由来しており、プレーンテキストモードのコードブロックでハイライトを使いたいというユースケースへの対応が求められていました。
技術的な変更
選択範囲がコードブロック内にあるかどうかを判定し、通常パスとコードブロック専用パスに処理を分岐させる設計が採用されました。
$toggleSelectionStyles 関数のシグネチャ変更:
変更前は editor インスタンスを受け取っていませんでしたが、コードブロック向けのネストされた editor.update() を呼び出す必要があるため、引数に追加されました。
// 変更前
function $toggleSelectionStyles(styles) {
// ...
$patchStyleText(selection, patch)
}
// 変更後
function $toggleSelectionStyles(editor, styles) {
// ...
if ($selectionIsInCodeBlock(selection)) {
$patchCodeHighlightStyles(editor, selection, patch)
} else {
$patchStyleText(selection, patch)
}
}
コードブロック判定関数 $selectionIsInCodeBlock:
function $selectionIsInCodeBlock(selection) {
const nodes = selection.getNodes()
return nodes.some((node) => {
const parent = $isCodeHighlightNode(node) ? node.getParent() : node
return $isCodeNode(parent)
})
}
選択中のノードが CodeHighlightNode である場合は親ノードを参照し、その親が CodeNode であるかを確認することで、コードブロック内の選択を正確に検出します。
$patchCodeHighlightStyles 関数による直接パッチ:
コードブロック向けのパスでは、canHaveFormat() → false の制約を回避するために、node.__style と node.__format を書き込み可能なノードのコピーに直接パッチします。また、ネストされた editor.update() には skipTransforms と discrete オプションを指定することで、シンタックスハイライトのトランスフォームが再実行されてスタイルが上書きされる問題を防いでいます。discrete による同期コミットは、ドロップダウンの editor.focus() が後続の更新サイクルを引き起こす前にスタイルを確定させるために必要です。
CodeHighlightNode 向けのノードトランスフォーム追加:
editor.registerNodeTransform(CodeHighlightNode, $syncHighlightWithCodeHighlightNode)
既存の TextNode 向けトランスフォーム $syncHighlightWithStyle と同様の同期処理を、CodeHighlightNode に対しても登録します。これにより、スタイルとノードの状態が一貫して保たれます。
ブラウザテストの追加:
test("color highlighting text in a plain-text code block", async ({ page, editor }) => {
await editor.setValue('<pre data-language="plain"><code>some log output</code></pre>')
await editor.select("log output")
await applyHighlightOption(page, "background-color", 1)
await editor.flush()
await assertEditorContent(editor, async (content) => {
await expect(content.locator("code mark")).toContainText("log output")
})
})
ハイライトの適用と解除の両方をカバーするブラウザテストが追加され、code mark セレクタで <mark> 要素の存在を確認しています。
設計判断
canHaveFormat() の制約を正面から迂回し、直接パッチする方式が採用されました。
Lexicalの公開APIである setFormat() や $patchStyleText() はこの制約の中では動作しないため、書き込み可能なノードの __style と __format に直接アクセスするアプローチが取られています。これはLexicalの内部実装に依存する方法ですが、PR説明中では「workaround」として明示されており、制約を認識した上での判断です。
skipTransforms と discrete の組み合わせは、更新順序の制御に関する重要な設計判断です。discrete により同期的なコミットを強制することで、フォーカスイベントが引き起こす後続の更新サイクルよりも先にスタイルを確定させています。skipTransforms はその更新内でシンタックスハイライトのトランスフォームを抑制し、適用したスタイルが上書きされないことを保証します。この二つのオプションが揃って初めて、コードブロック内のハイライトが安定して動作します。
まとめ
本PRは、Lexicalの CodeHighlightNode が持つ書式設定の制約と、シンタックスハイライトトランスフォームによるスタイル上書きという二つの問題を、コードパスの分岐・直接パッチ・更新オプションの組み合わせで解決しました。プレーンテキストコードブロックという特定のユースケースに限定した変更であり、既存のリッチテキスト向けハイライト処理への影響はありません。