Trix `<div>` コンテンツを破壊しないイメージギャラリー変換の修正

basecamp/lexxy

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.rbfrom_trix_to_lexxy_test.rb)にもアタッチメントと同一ポスト内にテキストコンテンツが共存するケースが追加されました。これはまさに今回修正した「テキストとアタッチメントが混在する <div>」のシナリオを検証するものです。

設計判断

ギャラリー判定の責務を ImageGalleryNode 自身に閉じ込め、アンラップ処理の責務を AttachmentsExtension へ移動するという明確な責務分離が実現されました。

旧実装では editor.js がデコレーターノードのアンラップを担っていましたが、これはアタッチメントという特定ドメインの知識をエディタ本体が持つことを意味していました。AttachmentsExtension への移動により、アタッチメント関連の変換ロジックが一箇所に集約されています。また、ノード変換を registerNodeTransform で登録する方式は、Lexicalのアーキテクチャに沿ったアプローチであり、変換処理がLexicalのトランザクションサイクル内で適切に実行されます。

#isGalleryElement の判定では element.textContent.trim() === "" という条件が用いられています。これは子要素を再帰的にチェックするよりシンプルな実装で、テキストノードが混在している限りギャラリーとして扱わないという意図を明確に示しています。

まとめ

ギャラリー判定条件の厳格化とアンラップ処理の責務移動という2つの変更を組み合わせることで、Trix由来のHTMLをLexxyへインポートする際の変換精度が向上しました。特にアンラップ処理が $splitNode を活用した堅牢な実装へと進化したことで、アタッチメントとテキストが混在するコンテンツの編集シナリオを正しく処理できるようになっています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
7375248b

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)、背景・技術詳細・設計判断(各論)、まとめ(結論)という3部構成が明確で、ガイドラインに完全に準拠しています。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト(```javascript:path/to/file.js)およびGitHubのPRリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

内容はLexical/Lexxyフレームワークに関する専門的な知識を前提としており、対象読者であるエンジニアに適した技術レベルです。

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

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

各セクションが総論→各論の構成を取り、各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、非常に高い可読性を実現しています。

Diff内容との照合 ✓ PASS

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

記事で引用されているコードブロックは、提供されたDiffの内容と完全に一致しています。ファイル名や変更箇所(追加・削除)の説明も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`ImageGalleryNode`, `AttachmentsExtension`, `registerNodeTransform`, `$splitNode` といった技術用語が、文脈に沿って正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

ギャラリー判定ロジックの厳格化や、アンラップ処理の責務移動に関する説明は、Diffのコード変更と完全に整合しており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内の主張はすべて、PRのタイトル、Description、Diff内のコード変更によって裏付けられており、ハルシネーション(捏造)は検出されませんでした。

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

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

PR番号(#804)や、コード内のメソッド名、ファイルパスなどの固有名詞はすべて正確に記載されています。

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

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

記事のタイトルはPRのタイトル('Prevent Image Galleries removing trix <div> content')の内容を的確に要約しており、記事全体の主題と一致しています。

外部知識の正確性 ✓ PASS

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

バージョン情報やリリース予定など、PR情報に含まれない外部知識の追記はなく、提供された情報源に忠実な内容となっています。

時間表現の正確性 ✓ PASS

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

記事内には時間表現の歪曲は見られず、PRによる変更を客観的な事実として記述しています。