Trix `<div>` コンテンツを破壊しないイメージギャラリー変換の修正
Trixエディタが生成するテキスト混在の <div> 要素を誤ってイメージギャラリーに変換してしまうバグを修正しました。あわせて、アタッチメントノードのアンラップ処理を ParagraphNode から AttachmentsExtension へ移動し、責務の分離を実現しています。
背景
Trixエディタは本文コンテンツを <div> でラップして出力するため、Lexxyがそのコンテンツを読み込む際に問題が発生していました。<div> 内にテキストや見出し、装飾付きスパンなどが含まれていても、アタッチメント要素が1つでも存在すれば ImageGalleryNode へと誤変換されていたのです。
Trixが生成する典型的なHTMLは <div><h1>Title</h1></div> や <div><span style="color: red;">text</span></div> のような構造で、これらはギャラリーではなく通常のブロック要素として扱われるべきです。バグの根本原因は、従来の importDOM が「<div> 直下にアタッチメント要素が1つでも存在すること」だけをギャラリー判定の条件としていた点にありました。
技術的な変更
ImageGalleryNode のギャラリー判定を厳格化
ImageGalleryNode のギャラリー判定ロジックが強化され、「全ての子要素がアタッチメントであること」 を必須条件とするようになりました。
変更前:
div: (element) => {
const containsAttachment = element.querySelector(`:scope > :is(${this.#attachmentTags.join()})`)
if (!containsAttachment) return null
return {
conversion: () => {
return {
node: $createImageGalleryNode(),
after: children => children
}
},
priority: 2
}
}
変更後:
div: (element) => {
if (!this.#isGalleryElement(element)) return null
return {
conversion: () => {
return {
node: $createImageGalleryNode()
}
},
priority: 2
}
}
static #isGalleryElement(element) {
const attachmentChildren = element.querySelectorAll(`:scope > :is(${this.#attachmentTags.join()})`)
return element.textContent.trim() === ""
&& attachmentChildren.length > 0
&& element.children.length === attachmentChildren.length
}
新しい #isGalleryElement メソッドは3つの条件をすべて満たす場合のみギャラリーと判定します。テキストコンテンツが空であること、アタッチメント子要素が1つ以上存在すること、そして全子要素がアタッチメントであることです。また、after: children => children の指定も不要になり削除されています。
アンラップ処理を AttachmentsExtension へ移動
LexicalはDOMインポート時にデコレーターノードを ParagraphNode で自動的にラップすることがあります。これを解除するロジックが editor.js の #unwrapDecoratorNode メソッドとして実装されていましたが、このPRでは AttachmentsExtension へ移動しています。
変更前(editor.js):
#unwrapDecoratorNode(node) {
if ($isParagraphNode(node) && node.getChildrenSize() === 1) {
const child = node.getFirstChild()
if ($isDecoratorNode(child) && !child.isInline()) {
return child
}
}
return node
}
変更後(attachments_extension.js):
function $extractAttachmentFromParagraph(attachmentNode) {
const parentNode = attachmentNode.getParent()
if (!$isParagraphNode(parentNode)) return
if (parentNode.getChildrenSize() === 1) {
parentNode.replace(attachmentNode)
} else {
const index = attachmentNode.getIndexWithinParent()
const [ topParagraph, bottomParagraph ] = $splitNode(parentNode, index)
topParagraph.insertAfter(attachmentNode)
for (const p of [ topParagraph, bottomParagraph ]) {
if (p.isEmpty() p.remove()
}
}
}
この新実装は editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph) として登録されます。旧実装との重要な違いは、アタッチメントが段落の唯一の子ではない場合を扱える点です。$splitNode を使って段落を分割し、アタッチメントを段落の外へ取り出した後、空になった段落を除去するため、より堅牢な処理になっています。
テストの再設計
Rubyで書かれていたシステムテスト test/system/trix_html_test.rb が削除され、JavaScriptベースのブラウザテスト test/browser/tests/trix_html.test.js として再実装されました。既存のシステムテストではTrixのHTMLをDBに直接保存してから読み込む方式でしたが、新テストでは editor.setValue() を使ってエディタへ直接値を設定する方式を採用しています。
あわせて、既存のシステムテスト(from_lexxy_to_trix_test.rb・from_trix_to_lexxy_test.rb)にもアタッチメントと同一ポスト内にテキストコンテンツが共存するケースが追加されました。これはまさに今回修正した「テキストとアタッチメントが混在する <div>」のシナリオを検証するものです。
設計判断
ギャラリー判定の責務を ImageGalleryNode 自身に閉じ込め、アンラップ処理の責務を AttachmentsExtension へ移動するという明確な責務分離が実現されました。
旧実装では editor.js がデコレーターノードのアンラップを担っていましたが、これはアタッチメントという特定ドメインの知識をエディタ本体が持つことを意味していました。AttachmentsExtension への移動により、アタッチメント関連の変換ロジックが一箇所に集約されています。また、ノード変換を registerNodeTransform で登録する方式は、Lexicalのアーキテクチャに沿ったアプローチであり、変換処理がLexicalのトランザクションサイクル内で適切に実行されます。
#isGalleryElement の判定では element.textContent.trim() === "" という条件が用いられています。これは子要素を再帰的にチェックするよりシンプルな実装で、テキストノードが混在している限りギャラリーとして扱わないという意図を明確に示しています。
まとめ
ギャラリー判定条件の厳格化とアンラップ処理の責務移動という2つの変更を組み合わせることで、Trix由来のHTMLをLexxyへインポートする際の変換精度が向上しました。特にアンラップ処理が $splitNode を活用した堅牢な実装へと進化したことで、アタッチメントとテキストが混在するコンテンツの編集シナリオを正しく処理できるようになっています。