ActionTextをカスタマイズする

## はじめ
　本記事は、Rails7、stimulusを使用した環境のもと、ActionTextをカスタマイズ（主に添付ファイルのアップロードまわり）する方法を紹介します。ActionTextは、[trix](https://github.com/basecamp/trix)を利用しており、これによってリッチテキストコンテンツと編集機能を簡単に導入することができます。デフォルトの挙動のまま使用する分には特に問題にはなりませんが、「こうしてほしい」という要望に応えるためにカスタマイズしていくためには、それなりの理解が必要であり、その前提のもと話を進めていきます。

参考
[RailsガイドのActionTextに関する記事はこちら](https://railsguides.jp/action_text_overview.html)
[ActiveStorageのAPIはこちら](https://api.rubyonrails.org/v6.0.2.1/classes/ActiveStorage/Blob.html)
[trixのイベントはこちら](https://github.com/basecamp/trix#observing-editor-changes)

## 選択できるファイル形式を限定してほしい
``<input type="file" accept="image/*">``的なことを、ActionTextの装飾ボタンにも適用する

```erb:hoge.html.erb
<div class=data-controller="action-text">
  <%= f.rich_text_area :description, data: { action: "trix-initialize->action-text#trixInitialize } %>
</div>
```

```js:action_text_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  trixInitialize() {
    // 初期設定のpickFilesを上書きする
    // FYI: https://www.npmjs.com/package/trix?activeTab=explore
    Trix.config.input.pickFiles = () => {
      const fileInput = document.createElement("input")

      // image/jpeg, image/png, image/gifに限定
      fileInput.setAttribute("type", "file")
      fileInput.setAttribute("accept", "image/jpeg, image/png, image/gif")
      fileInput.setAttribute("multiple", "true")
      fileInput.dataset.action = "trix-file-accept->trix#uploadfile"

      fileInput.addEventListener("change", () => {
        const {files} = fileInput;
        Array.from(files).forEach(insertAttachment)
      });

      fileInput.click()
    }

    const insertAttachment = (file) => {
      const trixEditor = document.querySelector("trix-editor").editor;
      trixEditor.insertFile(file);
    }
  }
}
```
## ダイレクトアップロードを使わないでほしい
　ActionTextは、デフォルトでjsからS3にダイレクトアップロードするようになっています。インフラ担当が別にいる場合は細かな設定がわからないため、IAMロールにポリシーを設定するお願いをしたりなど協力しながら動作確認を行わないといけないので意外と面倒な時があります。
　一般的に、Rails経由でアップロードしたほうがセキュリティ的にも安全で自然だと思います。

```erb:hoge.html.erb
<div class="form-control-container" data-controller="action-text" data-action-text-url-value="<%= admin_action_text_attachments_path %>">
  <%= f.rich_text_area :description, data: { action: "trix-file-accept->action-text#trixFileAccept trix-attachment-add->action-text#trixAttachmentAdd" } %>
</div>
```

```js:action_text_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    url: String
  }

  // リクエストを送信するときに、Railsが発行したCSRFトークンも一緒に送るようにする。
  connect() {
    this.csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
  }

  trixAttachmentAdd(event) {
    const attachment = event.attachment
    const file = attachment.file
    let formData = new FormData()

    formData.append("Content-Type", file.type)
    formData.append("attachment[file]", file)
    formData.append("attachment[model]", this.modelValue)

    if (file) {
      let xhr = new XMLHttpRequest
      xhr.open("POST", this.urlValue, true);
      xhr.setRequestHeader("X-CSRF-Token", this.csrfToken);

      xhr.upload.onprogress = function(event) {
        const progress = event.loaded / event.total * 100;
        attachment.setUploadProgress(progress);
      }

      xhr.onload = function() {
        if (xhr.status === 201) {
          const data = JSON.parse(xhr.responseText)

          // actoin_text_attachments_controller.rbから返された値を属性に設定することでリッチテキスト内で画像が表示される
          return attachment.setAttributes({
            url: data.url,
            sgid: data.signed_id
          })
        } else {
          throw new Error(`upload failed. status: ${xhr.status}`)
        }
      }

      return xhr.send(formData);
    } else {
      throw new Error("attachmet file does not exist")
    }
  }
}
```

```ruby:action_text_attachments_controller.rb
class Admin::ActionTextAttachmentsController < Admin::ApplicationController
  # s3のバケット直下にファイルを保存せず、ディレクトリを切って保存していく場合（bucket/admin/image.pngなど）、
  # そのパスを返すメソッドをconcerns/に持っておくことで他のコントローラでも使用できるようする。
  include ActiveStorage::UploadPath

  def create
    file = attachment_params[:file]
   
    return render json: {}, status: :bad_request if file.blank?

    # ActiveStorage::Blobのインスタンスが作成され保存されたのち、サービス上へアップロード
    # upload_pathは ActiveStorage::UploadPathに定義されたメソッド
    blob = ActiveStorage::Blob.create_and_upload!(key: upload_path, io: file,
                                                  filename: file.original_filename)

    # ここではCloudFront経由のURLを返す独自のヘルパーcdn_urlを使用。
    # リッチテキスト上にアップロードした画像を表示させるには、そのURLと新しく作成されたblobのsigned_idが必要
    render json: { url: cdn_url(blob), signed_id: blob.signed_id }, status: :created
  end

  private

  def attachment_params
    params.fetch(:attachment, {}).permit(:file)
  end
end
```

以上です。いい勉強になりました。