Trix互換のイメージギャラリー機能をLexicalベースのエディタに実装

basecamp/lexxy

Lexxyに ImageGalleryNodeAttachmentsExtension が追加され、複数の画像を自動でギャラリーにまとめる機能がTrixからの移行互換として実装されました。アップロード・ペースト・ドラッグ&ドロップのすべての経路でギャラリーが生成され、隣接するギャラリーは改行の削除によって自動的に統合されます。

背景

LexxyはTrixエディタのLexicalベースへの置き換えを目指しており、Trixが持つイメージギャラリー機能を互換実装する必要がありました。Trixでは複数の画像添付をまとめて「ギャラリー」として表示する機能があり、Lexxyでもこの挙動を再現することがゴールです。

PR説明には「Drop-in replacement for Trix image galleries」と明記されており、既存のTrixコンテンツを読み込んだ際のDOM変換も含め、インポート・エクスポートの両方向での互換性を担保する設計になっています。また、取り消し線で消された記述から、当初はギャラリー内のカーソル選択を常に NodeSelection に強制する方針も検討されたことがわかりますが、最終的にはその方針は採用されませんでした。

技術的な変更

本PRの中心は ImageGalleryNodeAttachmentsExtensionUploader の3つの新規クラスと、既存の複数クラスへの連携変更です。

ImageGalleryNode

src/nodes/image_gallery_node.js に追加された ImageGalleryNode は、Lexicalの ElementNode を継承したノードです。DecoratorNode ではなく ElementNode を選択したことで、子ノードとして ActionTextAttachmentNode を保持できる構造になっています。

ノードの自己整合性を保つ static transform() が定義されており、以下の順でギャラリーを変換します:

  • unwrapEmptyNode(): 子が0件のギャラリーを取り除く
  • replaceWithSingularChild(): 子が1件のギャラリーをアタッチメント単体に展開(unwrap)する
  • splitAroundInvalidChild(): プレビュー不可能な画像や非画像が混入した場合にギャラリーを分割する

DOM取り込みは static importDOM() で定義されており、div 要素の直下に添付ファイルタグが含まれる場合に ImageGalleryNode として変換します。after フックで子孫の有効なアタッチメントノードのみを抽出することで、不要な要素を除去しています。

static importDOM() {
  return {
    div: (element) => {
      const containsAttachment = element.querySelector(`:scope > :is(${this.#attachmentTags.join()})`)
      if (!containsAttachment) return null

      return {
        conversion: () => ({
          node: $createImageGalleryNode(),
          after: children => $descendantsMatching(children, this.isValidChild)
        }),
        priority: 2
      }
    }
  }
}

AttachmentsExtension

src/extensions/attachments_extension.js に新設された AttachmentsExtension は、enabled ゲッターで editorElement.supportsAttachmentstrue のときのみ登録される条件付き拡張です。これにより、添付機能を持たないエディタ構成には一切のノードやコマンドが追加されません。

これまで src/elements/editor.js 内で supportsAttachments を条件に nodes.push(...) していた登録ロジックが、AttachmentsExtensionlexicalExtension に移管されました。登録されるノードは以下の3つです:

  • ActionTextAttachmentNode
  • ActionTextAttachmentUploadNode
  • ImageGalleryNode

また、Lexicalの DELETE_CHARACTER_COMMAND に対して $collapseIntoGallery コマンドハンドラを登録することで、ギャラリーの隣接と統合ロジックをコマンドレベルで制御しています。

get lexicalExtension() {
  return defineExtension({
    name: "lexxy/action-text-attachments",
    nodes: [
      ActionTextAttachmentNode,
      ActionTextAttachmentUploadNode,
      ImageGalleryNode
    ],
    register(editor) {
      return mergeRegister(
        editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL)
      )
    }
  })
}

Uploader

src/editor/contents/uploader.js に追加された Uploader クラスは、ファイルのアップロードノード生成と挿入を担います。Uploader.for(editorElement, files) というファクトリメソッドが GalleryUploader を使用するかどうかを判定し、全ファイルがプレビュー可能な画像であれば GalleryUploader を返します。

これに合わせて、従来 contents.uploadFile(file) をループで呼んでいた箇所が contents.uploadFiles(files, { selectLast: true }) に統合されました。対象は clipboard.jscommand_dispatcher.jscontents.js の3ファイルにまたがります。

static for(editorElement, files) {
  const UploaderKlass = GalleryUploader.handle(editorElement, files) ? GalleryUploader : Uploader
  return new UploaderKlass(editorElement, files)
}

ActionTextAttachmentNode のインライン判定変更

src/nodes/action_text_attachment_node.jsisInline() メソッドが変更され、固定の false から動的な判定に切り替わりました。

変更前:

isInline() {
  return false
}

変更後:

isInline() {
  return this.isAttached() && !this.getParent().is($getNearestRootOrShadowRoot(this))
}

この変更により、ActionTextAttachmentNodeImageGalleryNode の子として配置されている場合はインラインノードとして振る舞い、ルート直下にある場合はブロックノードとして振る舞います。ギャラリー内の画像をフレックスレイアウトで横並びにするうえで、このインライン判定の動的化が不可欠です。

クリップボード処理の改善

src/editor/clipboard.js では、HTMLを含むペーストの処理が変更されました。従来はブラウザからコピーされた画像(text/html を持つ)をスキップしていましたが、変更後は contents.insertHtml(html, { tag: PASTE_TAG }) でHTMLとして挿入するようになりました。これにより、ギャラリーのHTMLをコピー&ペーストした場合も importDOM() 経由で正しく復元されます。

スタイルの変更

src/assets/stylesheets/lexxy-content.css では、.attachment-gallery がマージン制御の対象セレクタに追加されました。また、アタッチメントに関するスタイルがトップレベルの :where() から figure コンテキスト内の p:has(.attachment) セレクタに移動し、コンテンツ領域のスコープ管理が整理されています。

src/assets/stylesheets/lexxy-editor.css では、.node--selected の選択リングスタイルが簡略化されました。

変更前:

.node--selected {
  &:has(img) img,
  &:not(:has(img)) {
    outline: var(--lexxy-focus-ring-size) solid var(--lexxy-focus-ring-color);
    outline-offset: var(--lexxy-focus-ring-offset);
  }
}

変更後:

.node--selected {
  outline: var(--lexxy-focus-ring-size) solid var(--lexxy-focus-ring-color);
  outline-offset: var(--lexxy-focus-ring-offset);
}

ギャラリーノード自体に選択リングを当てるシンプルな設計になっており、img要素を直接スタイリングしていた複雑な条件分岐が不要になっています。

設計判断

ElementNode の選択と transform() による自己整合性保証 が、この実装の核心的な設計判断です。

ギャラリーを DecoratorNode ではなく ElementNode として実装したことで、Lexicalのノードツリー上で子ノードを直接管理できます。そして static transform() を登録することで、ギャラリーが空になった・単一になった・不正な子を持つようになったといった状態を、Lexicalの更新サイクルの中で自動的に解消できます。この「ノードが自分の整合性を自分で直す」パターンにより、ギャラリーの崩壊ケースを呼び出し側で個別にハンドリングする必要がなくなっています。

AttachmentsExtension への責務集約 も注目すべき判断です。従来 editor.js 内でフラグ条件によって分散していたノード登録・コマンド登録を、enabled ゲッターを持つ単一の拡張クラスに集約することで、添付機能の有無による条件分岐がエディタのコアから切り離されました。

Uploader のファクトリパターン は、通常の単一ファイルアップロードとギャラリーアップロードの切り替えを呼び出し側から隠蔽します。uploadFiles() というAPIを統一しつつ、内部でファイルセットの性質を見て適切なアップローダを選択する設計は、将来的に別種のアップローダを追加する際も既存コードへの影響を最小化します。

まとめ

本PRは、ImageGalleryNode(ElementNode)・AttachmentsExtension(条件付き拡張)・Uploader(ファクトリパターン)という3つの責務を持つクラスを軸に、Trix互換のギャラリー機能をLexicalのアーキテクチャに沿う形で実装しています。transform() による自己整合性保証と isInline() の動的化を組み合わせることで、ギャラリー内外でのノードの振る舞いの違いをLexicalのノードシステムに適切に委譲した点が、この変更の核心的な設計といえます。

記事メタデータ

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

この記事は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:src/nodes/image_gallery_node.js)やPR番号のリンク記法([PR #716](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

「ElementNode」「DecoratorNode」「ファクトリパターン」などの技術用語を適切に用いており、専門知識を持つエンジニアという対象読者に適合した内容です。

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

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

各セクションの冒頭に要旨を述べる総論パラグラフが配置され、各段落もトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が遵守されており、可読性が高いです。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロック(ImageGalleryNode, AttachmentsExtension, isInline()の変更など)は、提供されたDiff情報と正確に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

Lexicalの「ElementNode」「transform」「importDOM」や、一般的な設計パターンの「ファクトリパターン」など、技術用語が文脈に応じて正確に使用されています。

説明の技術的正確性 ✓ PASS

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

「ElementNodeを選択した理由」や「isInline()の動的化の意義」など、コード変更の背景にある技術的な理由が、Diffの内容に基づいて正確に説明されています。

事実の突合 ✓ PASS

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

記事内のすべての主張(Trix互換、ギャラリーの統合ロジック、採用されなかった設計方針など)は、PRのDescriptionやDiff内のコード・テストコードによって裏付けられており、ハルシネーションは見られません。

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

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

PR番号「#716」が正確に記載・リンクされています。

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

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

記事のタイトル「Trix互換のイメージギャラリー機能をLexicalベースのエディタに実装」は、PRのタイトル「Image galleries」およびDescriptionの内容を的確に要約しています。

外部知識の正確性 ✓ PASS

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

記事はPRで提供された情報のみに基づいており、バージョンサポート状況など、PR外の不確かな外部知識を持ち込んでいません。

時間表現の正確性 ✓ PASS

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

記事内に時間表現に関する記述はなく、PRの時間情報を歪曲していません。