[basecamp/lexxy] プロンプトアイテムから複数のアタッチメント挿入とコンテンツタイプのカスタマイズが可能に

basecamp/lexxy

変更の背景

Lexxy(Lexical.jsベースのリッチテキストエディタライブラリ)では、従来、1つのプロンプトアイテムから1つのアタッチメントしか挿入できませんでした。この制約は、ユーザーグループのメンション機能など、1つの選択から複数のアタッチメントを生成したいユースケースに対応できないという課題がありました。

また、Action Textのアタッチメントで使用される content-typeapplication/vnd.actiontext.{type} という固定フォーマットでしたが、Rails以外のアプリケーションや独自の命名規則を持つプロジェクトでは、この名前空間をカスタマイズしたいというニーズがありました。

実装された機能

1. 複数アタッチメントのサポート

単一のプロンプトアイテム内に複数の <template type="editor"> 要素を配置できるようになりました。これにより、1つの選択操作で複数のアタッチメントを一括挿入できます。

実装例(グループメンション):

<lexxy-prompt-item search="<%= locals[:group_name] %>">
  <template type="menu">
    <span class="person person--prompt-item">
      <span class="person--avatar"><%= "GR" %></span>
      <span class="person--name"><%= locals[:group_name] %></span>
    </span>
  </template>

  <% locals[:people].each do |person| %>
    <template type="editor" sgid="<%= person.attachable_sgid %>" content-type="application/vnd.actiontext.group_mention">
      <span class="person person--inline">
        <span class="person--name"><%= person.name %></span>
      </span>
    </template>
  <% end %>
</lexxy-prompt-item>

この例では、グループを選択すると、そのグループに属する全メンバーのアタッチメントが個別に挿入されます。各 <template type="editor"> には独自の sgidcontent-type を指定できます。

2. コンテンツタイプ名前空間の設定可能化

グローバル設定で attachmentContentTypeNamespace を変更できるようになりました。

設定方法:

import * as Lexxy from "lexxy"

Lexxy.configure({ 
  global: { 
    attachmentContentTypeNamespace: "myapp" 
  } 
})

// デフォルトのcontent-typeが application/vnd.myapp.{type} になる

実装の詳細:

constructor({ tagName, sgid, contentType, innerHtml }, key) {
  super(key)

  const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace")

  this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME
  this.sgid = sgid
  this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`
  this.innerHtml = innerHtml
}

デフォルト値は "actiontext" のため、既存の動作に影響はありません。

技術的な変更点

プロンプトアイテムの処理ロジック

従来の単一テンプレート処理から、複数テンプレートの配列処理に変更されました。

変更前:

const template = promptItem.querySelector("template[type='editor']")
this.#insertTemplateAsAttachment(promptItem, template, stringToReplace)

変更後:

const templates = Array.from(promptItem.querySelectorAll("template[type='editor']"))
const stringToReplace = `${this.trigger}${this.#editorContents.textBackUntil(this.trigger)}`

if (this.hasAttribute("insert-editable-text")) {
  this.#insertTemplatesAsEditableText(templates, stringToReplace)
} else {
  this.#insertTemplatesAsAttachments(templates, stringToReplace, promptItem.getAttribute("sgid"))
}

アタッチメント間のスペース挿入

複数のアタッチメントを挿入する際、各アタッチメント間に自動的にスペーサーテキストノードが挿入されます。

#insertTemplatesAsAttachments(templates, stringToReplace, fallbackSgid = null) {
  this.#editor.update(() => {
    const attachmentNodes = this.#buildAttachmentNodes(templates, fallbackSgid)
    const spacedAttachmentNodes = attachmentNodes.flatMap(node => [ node, this.#getSpacerTextNode() ]).slice(0, -1)
    this.#editorContents.replaceTextBackUntil(stringToReplace, spacedAttachmentNodes)
  })
}

flatMapslice(0, -1) のパターンにより、最後のアタッチメントの後ろにはスペースが挿入されないように制御されています。

sgid属性の優先順位

sgid 属性は以下の優先順位で解決されます:

  1. <template type="editor"> 要素の sgid 属性
  2. <lexxy-prompt-item> 要素の sgid 属性(fallback)

これにより、グループ全体のsgidと個別メンバーのsgidを柔軟に使い分けられます。

システムテスト

以下のテストケースが追加され、動作が保証されています:

test "prompt with multiple attachables" do
  find_editor.send "4"
  click_on_prompt "Group 0"

  find_editor.within_contents do
    assert_selector %(action-text-attachment[content-type="application/vnd.actiontext.group_mention"]), count: 5
  end

  all("action-text-attachment").map { |el| el["sgid"] }.uniq.size == 5
end

test "global custom content-type of mentions" do
  visit edit_post_path(posts(:empty), attachment_content_type_namespace: "myapp")

  find_editor.send "1"
  click_on_prompt "Peter Johnson"

  assert_selector %(action-text-attachment[content-type="application/vnd.myapp.mention"])
  assert_no_selector %(action-text-attachment[content-type="application/vnd.actiontext.mention"])
end

互換性

  • 既存の単一アタッチメントプロンプトはそのまま動作します
  • デフォルトの content-type 名前空間は "actiontext" のため、既存コードへの影響はありません
  • sgid 属性は <lexxy-prompt-item> レベルでの指定も引き続きサポートされます

記事メタデータ

Generated by:
Claude Sonnet 4.5 for DiffDaily

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

品質レビュー結果

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

Review Criteria:

ガイドライン準拠 ✓ PASS

記事構成とDiffDaily Styleへの準拠状況

記事構成の3要素(Title, Context, Technical Detail)が明確に記載されており、カスタムMarkdown構文(コードブロック前後の空行、ファイル名付きハイライト、GitHubリンク)も正しく使用されています。対象読者であるエンジニアに適した技術レベルで書かれています。

  • 記事構成(Title、Context、Technical Detail)
  • DiffDaily Styleガイド準拠
  • カスタムMarkdown活用
  • 対象読者への適合性
技術的整合性 ✓ PASS

技術的な正確性と表現の適切性

記事内で引用されているコードはPRの主題である「複数アタッチメント対応」と「content-typeのカスタマイズ」を的確に示しており、Diff内容を正確に反映していると判断できます。技術用語(sgid, content-typeなど)の使用も正確で、技術的な説明に誤りはありません。

  • 技術用語の正確性
  • コード例の正確性
  • 説明の技術的正確性
PR内容との整合性 ✓ PASS

元のPR情報との一致度

記事のすべての主張は、PRのタイトルや引用されたコード変更によって裏付けられており、ハルシネーションは検出されませんでした。PR番号やタイトルもPR情報と完全に一致しており、PRの内容を忠実に伝えています。

  • タイトル・説明の一致
  • Diff内容の正確な反映
  • 推測の排除