プレーンテキストのコードブロック内でテキストハイライトを有効化

basecamp/lexxy

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.__stylenode.__format を書き込み可能なノードのコピーに直接パッチします。また、ネストされた editor.update() には skipTransformsdiscrete オプションを指定することで、シンタックスハイライトのトランスフォームが再実行されてスタイルが上書きされる問題を防いでいます。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」として明示されており、制約を認識した上での判断です。

skipTransformsdiscrete の組み合わせは、更新順序の制御に関する重要な設計判断です。discrete により同期的なコミットを強制することで、フォーカスイベントが引き起こす後続の更新サイクルよりも先にスタイルを確定させています。skipTransforms はその更新内でシンタックスハイライトのトランスフォームを抑制し、適用したスタイルが上書きされないことを保証します。この二つのオプションが揃って初めて、コードブロック内のハイライトが安定して動作します。

まとめ

本PRは、Lexicalの CodeHighlightNode が持つ書式設定の制約と、シンタックスハイライトトランスフォームによるスタイル上書きという二つの問題を、コードパスの分岐・直接パッチ・更新オプションの組み合わせで解決しました。プレーンテキストコードブロックという特定のユースケースに限定した変更であり、既存のリッチテキスト向けハイライト処理への影響はありません。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
a0a45bcd

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)という「総論→各論→結論」の構成が明確に適用されており、理想的な記事構成です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きのコードブロック構文(```javascript:path/to/file.js)とPR番号のリンク記法([PR #856](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Lexicalの`CodeHighlightNode`や`editor.update`オプションなど、専門的な技術用語を前提としており、対象読者であるエンジニアに適した内容です。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論の構成になっており、各段落の冒頭にトピックセンテンスが置かれているため、非常に読みやすい構造です。1段落1トピックの原則も守られています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているすべてのコードスニペット(`$toggleSelectionStyles`の変更、`$selectionIsInCodeBlock`の追加など)は、提供されたDiff情報と正確に一致しています。ファイルパスの指定も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PRで言及されている`CodeHighlightNode`, `canHaveFormat()`, `skipTransforms`, `discrete`といった技術用語を正確に使用し、その役割を正しく解説できています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

「`canHaveFormat()`の制約」と「シンタックスハイライトによるスタイル上書き」という2つの問題点を挙げ、それぞれに対する解決策(直接パッチ、更新オプションの利用)を技術的に正確に説明しています。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張(`skipTransforms`と`discrete`の役割、直接パッチが「workaround」であることなど)は、PRのDescriptionで明確に裏付けられており、ハルシネーションは一切ありません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#856)が正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトル「プレーンテキストのコードブロック内でテキストハイライトを有効化」は、PRの主題「Support text highlighting in plain-text code blocks」を正確に反映しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事内容はPR情報とDiffに限定されており、バージョン情報やサポート状況といったPR外の知識を持ち込んでいません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現は適切であり、PRで完了した変更を事実として記述しています。誤解を招くような表現はありません。