Trix互換のイメージギャラリー機能をLexicalベースのエディタに実装
Lexxyに ImageGalleryNode と AttachmentsExtension が追加され、複数の画像を自動でギャラリーにまとめる機能がTrixからの移行互換として実装されました。アップロード・ペースト・ドラッグ&ドロップのすべての経路でギャラリーが生成され、隣接するギャラリーは改行の削除によって自動的に統合されます。
背景
LexxyはTrixエディタのLexicalベースへの置き換えを目指しており、Trixが持つイメージギャラリー機能を互換実装する必要がありました。Trixでは複数の画像添付をまとめて「ギャラリー」として表示する機能があり、Lexxyでもこの挙動を再現することがゴールです。
PR説明には「Drop-in replacement for Trix image galleries」と明記されており、既存のTrixコンテンツを読み込んだ際のDOM変換も含め、インポート・エクスポートの両方向での互換性を担保する設計になっています。また、取り消し線で消された記述から、当初はギャラリー内のカーソル選択を常に NodeSelection に強制する方針も検討されたことがわかりますが、最終的にはその方針は採用されませんでした。
技術的な変更
本PRの中心は ImageGalleryNode・AttachmentsExtension・Uploader の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.supportsAttachments が true のときのみ登録される条件付き拡張です。これにより、添付機能を持たないエディタ構成には一切のノードやコマンドが追加されません。
これまで src/elements/editor.js 内で supportsAttachments を条件に nodes.push(...) していた登録ロジックが、AttachmentsExtension の lexicalExtension に移管されました。登録されるノードは以下の3つです:
ActionTextAttachmentNodeActionTextAttachmentUploadNodeImageGalleryNode
また、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.js・command_dispatcher.js・contents.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.js の isInline() メソッドが変更され、固定の false から動的な判定に切り替わりました。
変更前:
isInline() {
return false
}
変更後:
isInline() {
return this.isAttached() && !this.getParent().is($getNearestRootOrShadowRoot(this))
}
この変更により、ActionTextAttachmentNode が ImageGalleryNode の子として配置されている場合はインラインノードとして振る舞い、ルート直下にある場合はブロックノードとして振る舞います。ギャラリー内の画像をフレックスレイアウトで横並びにするうえで、このインライン判定の動的化が不可欠です。
クリップボード処理の改善
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のノードシステムに適切に委譲した点が、この変更の核心的な設計といえます。