コードブロック内ハイライトの保存・再編集サイクルでの消失バグを修正
Lexicalのコード再トークナイザが<mark>ハイライトスタイルを破壊する問題を、3段階のワークアラウンドで解消しました。ハイライト済みのコードブロックを保存して再編集しても、スタイルが失われなくなります。
背景
プレーンテキストのコードブロック内でテキストをハイライトすると、エディタ上では正しく表示されるものの、保存して再編集すると書式が失われるというバグが報告されていました(Fizzy card #3388)。
根本原因は、LexicalのHTML読み込み処理にあります。コード再トークナイザはsetValueの更新サイクル内でTextNodeトランスフォームとして動作し、<mark>要素でスタイルが付与されたTextNodeを、スタイルを持たない新鮮なCodeHighlightNodeに置き換えてしまいます。この置き換えによって<mark>由来のハイライトスタイルが完全に失われていました。
この問題を修正するには、Lexicalの内部処理に起因する3つの独立した障壁を乗り越える必要がありました。
技術的な変更
修正は「HTMLインポート時にハイライト範囲を退避 → 再トークナイズ後に再適用」という2フェーズのアプローチで実現されています。
フェーズ1: ハイライト範囲の退避
src/extensions/highlight_extension.jsにpendingCodeHighlightsというWeakMapを導入し、HTMLインポート中に<mark>要素から抽出したハイライト範囲を一時保存します。WeakMapを使うことでエディタインスタンスをキーとし、エディタが破棄された際にエントリが自動的にガベージコレクトされます。
// Stores pending highlight ranges extracted during HTML import, keyed by CodeNode key.
// After the code retokenizer creates fresh CodeHighlightNodes, a mutation listener
// reads this map and re-applies the highlight styles. Scoped per editor instance
// so entries don't leak across editors or outlive a torn-down editor.
const pendingCodeHighlights = new WeakMap()
フェーズ2: 再トークナイズ後の再適用
再トークナイザが完了した後、CodeNodeのミューテーションリスナーがpending状態のハイライトを検出し、discreteかつskipTransformsな更新としてスタイルを再適用します。
return mergeRegister(
// ... 既存のトランスフォーム ...
editor.registerMutationListener(CodeNode, (mutations) => {
$applyPendingCodeHighlights(editor, mutations)
}, { skipInitialization: true })
)
この修正には、Lexical内部の3つの障壁に対するワークアラウンドが含まれています。
障壁1: html.importコンバータの衝突
Lexicalは拡張機能のhtml.importコンバータをObject.assignでマージするため、同一タグ名(pre)に対するコンバータは一つしか登録できません。修正では$registerPreConversion(editor)を用いて、エディタの内部キャッシュ_htmlConversionsに直接コンバータを登録します。<mark>要素が存在しない場合はnullを返してデフォルトのCodeNodeコンバータに処理を委ねます。
障壁2: 再トークナイザによるスタイル破壊
skipTransformsタグを付けたdiscrete更新でハイライトを再適用することで、再トークナイザが再び起動してスタイルを上書きする無限ループを防いでいます。
障壁3: splitText()がTextNodeを生成する問題
ハイライト境界でのCodeHighlightNodeの分割にsplitText()を使うと、戻り値がCodeHighlightNodeではなく素のTextNodeになります。修正では分割後に正しいハイライトタイプとスタイルを持つCodeHighlightNodeインスタンスを手動で生成して置き換えます。
レンダリングビューへの対応
src/helpers/code_highlighting_helper.jsにも変更が加えられています。Prismによるシンタックスハイライト処理前にextractHighlightRanges()で<mark>範囲を退避し、Prismの処理後にapplyHighlightRanges()で再適用することで、保存済みコンテンツのレンダリング時にもハイライトが保持されます。
// Extract highlight ranges before Prism destroys <mark> elements
const highlights = extractHighlightRanges(preElement)
const highlightedHtml = Prism.highlight(code, grammar, language)
const codeElement = createElement("code", { "data-language": language, innerHTML: highlightedHtml })
if (highlights.length > 0) {
applyHighlightRanges(codeElement, highlights)
}
設計判断
非同期の2フェーズ設計が採用されました。ハイライト範囲の保存と再適用を同一の更新サイクル内で完結させようとすると、再トークナイザのトランスフォームと競合します。WeakMapによる一時保存とミューテーションリスナーによる後続適用に分割することで、Lexicalの更新サイクルの制約を回避しています。
コンバータが<mark>要素を持たない<pre>に対してnullを返す設計も注目に値します。この「責務の委譲」パターンにより、既存のCodeNodeコンバータとの共存を実現し、_htmlConversionsへの直接登録という内部APIへの依存を最小限の影響範囲に限定しています。
また、AGENTS.mdに「Action Textのパーシステンス検証」セクションが追加され、エディタHTMLがDOMPurify(クライアント)・Loofah(サーバー)・highlightCode()(レンダリング)の3層を通過することを明示しました。インラインスタイルやノードタイプを変更する際には、このフルラウンドトリップを必ず検証するルールが組織的な知識として文書化されました。
まとめ
Lexicalの内部処理(Object.assignによるコンバータ衝突、再トークナイザによるスタイル破壊、splitText()の型問題)という3つの制約を、WeakMapを用いた2フェーズ設計で回避した修正です。特定の内部APIへの依存は最小限に抑えつつ、エディタ・保存・レンダリング・再編集の全サイクルでコードブロックのハイライトが保持されるようになりました。