コードブロックのインデント保持とハイライト位置の同期
コードブロック内の先頭空白がエディタから保存、再レンダリングの往復で失われ、ハイライトの <mark> が誤った文字列に付与されていた問題を解決しました。highlightElement が行っていたテキスト抽出とハイライト抽出が分離していたために起きていた同期ずれを、単一走査で統一的に取得する実装に置き換えています。これにより、インデントや ASCII テーブルの形状が維持され、ハイライトが正しい位置に描画されます。
背景
コードブロックのリードインデントや箱形テーブルが、エディタ → 保存 → レンダリングのサイクルで行頭空白が削除され、ハイライトの色付けが別の文字にずれる事象が報告されていました。根本原因は highlightElement が DOMParser().parseFromString(...).body.textContent でコード文字列を取得していたことで、HTML パーサが空白を正規化し、先頭空白が失われていた点にあります。さらにハイライト範囲は extractHighlightRanges が元の DOM で計算していたため、短縮された文字列に対してオフセットが適用され、結果としてハイライトがずれていました。
この問題はインデントが必須となるコード例や表形式のテキストで顕著に現れ、ユーザー体験を損なうため修正が必要でした。コードブロックの内容とビジュアルハイライトの整合性を保つことは、エディタ機能の信頼性に直結します。したがって、テキスト抽出とハイライト抽出を同一の文字列基盤で行う方針が採られました。
技術的な変更
highlightElement は、コード文字列とハイライト情報を同時に取得する新しいヘルパー extractCodeAndHighlights を呼び出すように変更されました。従来は code = preElement.innerHTML.replace(/<br\s*\/?>(?i)/gi, "\n") でコードだけを取得し、別途 extractHighlightRanges でハイライトを抽出していました。
@@ -12,16 +12,16 @@ export function highlightElement(preElement) {
if (preElement.dataset.highlighted === "true") return
const language = preElement.getAttribute("data-language")
- let code = preElement.innerHTML.replace(/<br\s*\/??>/gi, "\n")
@@
- // Extract highlight ranges before Prism destroys <mark> elements
- const highlights = extractHighlightRanges(preElement)
-
- // unescape HTML entities in the code block
- code = new DOMParser().parseFromString(code, "text/html").body.textContent || ""
+ // Read the source text and <mark> ranges in a single walk, before Prism
+ // rewrites the element. Sharing one traversal keeps the highlight offsets
+ // aligned with the code string and preserves leading whitespace — deriving
+ // either of them separately (e.g. textContent through DOMParser) collapses
+ // leading whitespace and shifts every range, re-indenting the rendered block.
+ const { code, highlights } = extractCodeAndHighlights(preElement)
extractCodeAndHighlights は <pre> 内を再帰的に走査し、テキストノードはそのまま文字列に、<br> 要素は改行文字として処理し、 の位置で
<mark> の開始・終了を記録します。これにより、コード文字列とハイライト範囲が同一の文字ストリーム上のオフセットで表現され、空白が保持されたまま正確にマッピングされます。
function extractCodeAndHighlights(preElement) {
const root = preElement.querySelector("code") || preElement
const highlights = []
let code = ""
function walk(node) {
if (node.nodeType === Node.TEXT_NODE) {
code += node.textContent
} else if (node.nodeType === Node.ELEMENT_NODE) {
if (node.tagName === "BR") {
code += "\n"
} else if (node.tagName === "MARK") {
const start = code.length
walk(node.firstChild) // recurse into mark's children
const end = code.length
highlights.push({ start, end, style: node.getAttribute("style") })
} else {
node.childNodes.forEach(walk)
}
}
}
walk(root)
return { code, highlights }
}
この変更に合わせて、インデント保持とハイライト位置の検証を行うテストが 3 件追加されました。テストはインデントされたコード、インデント付き ASCII テーブル、インデント内のハイライトの三種を対象とし、pre.textContent と <mark> の内容・スタイルが期待通りであることを確認します。回帰テストの導入により、将来的な変更で同様の問題が再発しにくくなります。
設計判断
テキスト抽出とハイライト抽出を 単一走査 に統合した設計は、空白正規化という副作用を排除しつつ、アルゴリズムの複雑性を増やさないことを目的としています。DOMParser を使用しないことで HTML パーサの暗黙的な正規化を回避し、コードブロックの リテラル な表現を保持できる点が重要でした。既存の API (highlightElement) は変更せず内部実装だけを差し替えることで、呼び出し側への互換性を確保しています。
このアプローチは、機能追加よりも既存挙動の正確性向上に重点を置く設計方針と一致します。新しいヘルパーは内部利用に留め、外部から直接呼び出す必要がないため、将来的に実装をさらにリファクタリングしても公開インターフェースは安定したままです。結果として、エディタのレンダリングロジックが決定論的に動作し、バグ再発リスクが低減しました。
まとめ
本PRは、コードブロックの先頭空白が失われる問題とハイライトオフセットのずれを根本的に解消し、インデント保持とハイライト位置の同期 を実現しました。単一走査でテキストとハイライト範囲を取得することで、HTML パーサの副作用を排除し、既存 API 互換性を維持しつつ信頼性を向上させています。