🦓

[Rails]ドラッグ&ドロップで画像をアップロードする

2023/08/17に公開

はじめに

Active StorageとS3を使って、ログの投稿に画像のドラッグ&ドロップ機能を追加していきます。

Active StorageとS3の設定について

Active Storageのダイレクトアップロードを参考にしながら進めます。
https://railsguides.jp/active_storage_overview.html#ダイレクトアップロード

環境

Rails 7.0.4.3
ruby 3.2.1

stimulusコントローラーを作成する

フォーム内のドラッグ&ドロップに対応するコントローラーを作成します。

bin/rails g stimulus markdown_upload
      create  app/javascript/controllers/markdown_upload_controller.js

コントローラーを読み込む

作成したコントローラーを読み込みます。

app/javascript/controllers/index.js
import MarkdownUploadController from './markdown_upload_controller'
application.register('markdown-upload', MarkdownUploadController)

テキストエリア内data-controllerを追加する

app/views/logs/_form.html.erb
...
<%= form.text_area :content, rows: 20, 
    data: {
      controller: "markdown-upload",
      markdown_upload_url_value: rails_direct_uploads_url,
      action: "drop->markdown-upload#dropUpload"
    }
    class: 'block w-full...' 
%>

drop動作をリスニングし、markdown-uploadコントローラとdropUploadアクションがドラッグ&ドロップの処理を行う流れになります。

rails_direct_uploads_urlはActive StorageのデフォルトのURLヘルパーであり、アップロードURLを提供します。Active Storageを使用して直接アップロードするためのURLを提供する必要があります。

// (これはdata-direct-upload-urlを提供する)
const url = input.dataset.directUploadUrl
const upload = new DirectUpload(file, url)

https://railsguides.jp/active_storage_overview.html#ライブラリやフレームワークとの統合

DirectUploadモジュールを導入する

ダイレクトアップロード機能をJavaScriptフレームワークから利用したい場合や、ドラッグアンドドロップをカスタマイズしたい場合は、DirectUploadクラスを利用できます。

@rails/activestorageをダウンロードしてコントローラーに読み込みます。

bin/importmap pin @rails/activestorage   
Pinning "@rails/activestorage" to https://ga.jspm.io/npm:@rails/activestorage@7.0.7/app/assets/javascripts/activestorage.esm.js

https://www.npmjs.com/package/@rails/activestorage

import { DirectUpload } from "@rails/activestorage"

dropUploadアクションを作成する

ドロップ→ファイルを取得→ファイルにsigned_idを与え→Active Storage用URLを生成する→マークダウン形式のリンクとして挿入する→アップロード
このような流れになります。

ファイルを取得する

  dropUpload(e){
    e.preventDefault();
    Array.from(e.dataTransfer.files).forEach(file => this.uploadFile(file));
  }

blobに保存させる

  uploadFile(file){
    const upload = new DirectUpload(file, this.urlValue);
    upload.create((error, blob) => {
      if (error) {
        alert(error);
      } else {
        console.dir(blob);
	const text = this.markdownLink(blob);
      }
    })
  }

signed_id は、ファイルのアップロード中に一時的に生成される一意の識別子です。
ファイルがアップロードされると、そのファイルに対して一意のsigned_idが生成されます。
署名付きID(Signed ID)を使ったURLは次のような形になります。

/rails/active_storage/blobs/signed_id/file.jpg

アップロードされたファイルの情報は、Active StorageのBlobモデルに保存されます。

マークダウンURLを生成する

  markdownLink(blob) {
    const filename = blob.filename;
    const url = `/rails/active_storage/blobs/${blob.signed_id}/${filename}`;
    const prefix = (this.isImage(blob.content_type) ? '!' : '');

    return `${prefix}[${filename}](${url})\n`;
  }

const prefix = (this.isImage(blob.content_type) ? '!' : '');: アップロードされたファイルが画像の場合は ! をプレフィックスとして付け加え、それ以外の場合は空文字列を設定します。これにより、画像ファイルの場合はMarkdown内で画像として表示される形式のリンクになります。

マークダウンURLをテキストエリア内に挿入する

uploadFile(file) {
  const upload = new DirectUpload(file, this.urlValue);

  upload.create((error, blob) => {
    if (error) {
      console.log("Error");
    } else {
      const text = this.markdownLink(blob);
      const start = this.element.selectionStart;
      const end = this.element.selectionEnd;
      this.element.setRangeText(text, start, end);
    }
  });
}

const start = this.element.selectionStart;const end = this.element.selectionEnd;: 現在のテキストエリア内で選択されているテキストの開始位置と終了位置を取得します。

this.element.setRangeText(text, start, end);: テキストエリア内で選択された範囲に、生成されたURLを挿入します。

バリデーションを追加する

  if (!this.isValidFileType(file)) {
    alert("アップロード可能の画像形式がJPEG, PNG, GIFです。ファイル形式をご確認ください。");
    return;
  }

  if (!this.isValidFileSize(file)) {
    alert("1MB以下の画像をアップロードしてください。");
    return;
  }
    
  isValidFileType(file) {
    return this.isImage(file.type);
  }

  isValidFileSize(file) {
    const maxSize = 1 * 1024 * 1024; 
    return file.size <= maxSize;
  }

画像でないファイルや1MB以上のファイルをアップロードし、アラートが表示されることを確認します。

完成形

app/javascript/controllers/markdown_upload_controller.js
import { Controller } from "@hotwired/stimulus"
import { DirectUpload } from "@rails/activestorage"

// Connects to data-controller="markdown-upload"
export default class extends Controller {
  static values = { url: String };
  connect() {
  }

  dropUpload(e){
    e.preventDefault();
    Array.from(e.dataTransfer.files).forEach(file => this.uploadFile(file));
  }


  uploadFile(file){

    if (!this.isValidFileType(file)) {
      alert("アップロード可能の画像形式がJPEG, PNG, GIFです。ファイル形式をご確認ください。");
      return;
    }

    if (!this.isValidFileSize(file)) {
      alert("1MB以下の画像をアップロードしてください。");
      return;
    }

    const upload = new DirectUpload(file, this.urlValue);
    upload.create((error, blob) => {
      if (error) {
        console.log(error);
      } else {
        const text = this.markdownUrl(blob);
        const start = this.element.selectionStart;
        const end = this.element.selectionEnd;
        this.element.setRangeText(text,start,end)
      }
    })
  }

  markdownUrl(blob){
    const filename = blob.filename
    const url = `/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`;
    const prefix = (this.isImage(blob.content_type) ? '!' : '');

    return `${prefix}[${filename}](${url})\n`;
  }

  isImage(contentType){
    return ["image/jpeg", "image/gif", "image/png"].includes(contentType);
  }

  isValidFileType(file) {
    return this.isImage(file.type);
  }

  isValidFileSize(file) {
    const maxSize = 1 * 1024 * 1024; // 3MB in bytes
    return file.size <= maxSize;
  }
}

早速動作を確認してみます。
Image from Gyazo

S3にアップロードされたことを確認します。

21:43:08 web.1  | Started POST "/rails/active_storage/direct_uploads" for ::1 at 2023-08-17 21:43:08 +0900
21:43:08 web.1  |   ActiveRecord::SchemaMigration Pluck (0.9ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
21:43:08 web.1  | Processing by ActiveStorage::DirectUploadsController#create as JSON
21:43:08 web.1  |   Parameters: {"blob"=>{"filename"=>"dev-log.png", "content_type"=>"image/png", "byte_size"=>4608, "checksum"=>"wW3nC7jrg+AcfF4pnVWAbA=="}, "direct_upload"=>{"blob"=>{"filename"=>"dev-log.png", "content_type"=>"image/png", "byte_size"=>4608, "checksum"=>"wW3nC7jrg+AcfF4pnVWAbA=="}}}
21:43:08 web.1  |   TRANSACTION (0.2ms)  BEGIN
21:43:08 web.1  |   ActiveStorage::Blob Create (11.8ms)  INSERT INTO "active_storage_blobs" ("key", "filename", "content_type", "metadata", "service_name", "byte_size", "checksum", "created_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING "id"  [["key", "ekl43w84hcge5zrn5meu2g9v7e4m"], ["filename", "dev-log.png"], ["content_type", "image/png"], ["metadata", nil], ["service_name", "amazon"], ["byte_size", 4608], ["checksum", "wW3nC7jrg+AcfF4pnVWAbA=="], ["created_at", "2023-08-17 21:43:08.871993"]]
21:43:08 web.1  |   TRANSACTION (1.0ms)  COMMIT
21:43:08 web.1  |   S3 Storage (41.5ms) Generated URL for file at key: **************** (https://***********.s3.ap-northeast-1.amazonaws.com/ekl43w84hcge5zrn5meu2g9v7e4m?x-amz-acl=public-read&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIARVCZOLN47DOARCXF%2F20230817%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20230817T124308Z&X-Amz-Expires=300&X-Amz-SignedHeaders=content-length%3Bcontent-md5%3Bcontent-type%3Bhost&X-Amz-Signature=8531fd67df840d9536e2058e8bed9de13c43e204fa9a6ed593c0548fea51c0e6)
21:43:09 web.1  | Completed 200 OK in 544ms (Views: 0.3ms | ActiveRecord: 17.4ms | Allocations: 317670)
21:43:09 web.1  | 
21:43:09 web.1  | 

終わりに

ドラッグ&ドロップで画像をアップロードできるようになりました。

参考した記事にドロップ以外に画像のペーストについての記述もあります。
https://hybrd.co/posts/github-issue-style-file-uploader-using-stimulus-and-active-storage

Discussion