【Rails7】複数枚画像をドラッグ&ドロップで表示、アップロードする方法(Stimulusで実装)

以前、Active Storageで複数枚画像のプレビュー表示、およびアップロードする方法についてまとめました。(Stimulusで実装)

あわせて読みたい
【Rails7】ActiveStorageで複数枚画像を投稿・更新する(プレビュー表示も実装) ActiveStorageで複数枚画像を投稿(保存)する方法についての情報はたくさん出てきますが、正直どれも僕が思い描いていた画像アップロード機能のイメージとは違うかなぁ...

ただ、前回の実装ではドラッグ&ドロップの実装はしておらず。

今どきドラッグ&ドロップは当たり前だし、僕も1ユーザーとしてドラッグ&ドロップ機能は必須だと感じているので、この度は重い腰を上げて実装することとなりました(笑)

さて、今回は前回作成したActive Storageの複数枚画像アップロード機能に付け加える形で実装を進めていくため、画像アップロードの仕組みを詳しく知っておきたい方は前回の記事を先に見ておくことをおすすめします。

それでは参ります。

目次

開発環境

  • Ruby 3.1.2
  • Ruby on Rails 7.0.4
  • tailwindcss 3.1.8
  • M1 Macbook Air 2020
  • mac OS Monterey (ver. 12.4)
  • ターミナル bash (Rosetta 2 使用

出来上がりイメージ

出来上がりイメージは以下の通りです。

ファイル選択ボタンおよびドラッグ&ドロップの両方のやり方でアップロードできるようにしています。

前回同様、画像のアップロード枚数制限、および1枚あたりの容量制限も設けています。

画像のドラッグ&ドロップ実装の流れ

今回は以下の前提条件のもと実装を進めていきます。

  • Ruby on Rails 7.0.4 で実装
  • JavaScriptフレームワークはStimulusを使用
  • TailwindCSS導入済み
  • Postモデル作成済み
  • Active Storage導入済み
  • 複数枚画像アップロード実装済み(こちらの記事を参照)

全体のコードはgithubに上げておきました。

GitHub
GitHub - hirokirokki0820/images-drug-and-drop-7.0.4 Contribute to hirokirokki0820/images-drug-and-drop-7.0.4 development by creating an account on GitHub.

それでは進めていきます。

画像のドラッグ&ドロップエリアを作成する

画像を投稿・編集、および表示するためのビュー_form.html.erbに以下のコードを追記します。(見やすくするため一部改行しています)

<%= form_with(model: @post, local: true, data: { controller: "images"}) do |f| %>

  <%= render 'layouts/errors', obj: @post %>

  <div class="mb-3">
    <%= f.label :title, "タイトル", class: "font-semibold block p-1" %>
    <%= f.text_field :title, autofocus: true, class: "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" %>
  </div>

  /* 画像ファイルの選択 */
  <div class="mb-3">
    <%= f.label :images, "画像", class: "font-semibold block p-1" %>
    <%= f.file_field :images, multiple: true, accept: "image/jpeg,image/gif,image/png", data: { images_target: "select", action: "change->images#selectImages" } %>
    <p data-images-target="error" class="text-red-600"></p>
  </div>

/******************************** ↓追記する内容 *********************************/
  /* 画像のドロップエリア */
  <div class="mb-3 py-5 bg-gray-50 text-center border-dashed border-2" 
   data-images-target="drop" data-action="dragover->images#dragover 
   dragleave->images#dragleave drop->images#dropImages">
    <i class="fa-regular fa-square-plus"></i>
    <span>ここに画像をドラッグ&ドロップ
  </div>
/*****************************************************************************/

  /* 画像のプレビュー */
  <div data-images-target="preview">
    <% if @post.images.attached? %>
      <% @post.images.each do |image| %>
        <div class="image-box inline-flex mx-1 mb-5" data-controller="images" data-images-target= "image_box">
          <div class="text-center">
            <%= image_tag image, width: "100" %>
            <a class="link cursor-pointer" data-action="click->images#deleteImage">削除</a>
            <%= f.hidden_field :images, name: "post[images][]", style: "display: none", value: "#{image.blob.id}" %>
          </div>
        </div>
      <% end %>
    <% end %>
  </div>

  <div class="mb-3">
    <%= f.submit  "投稿する", class: "btn btn-sky" %>
  </div>
<% end %>

後ほど、Stimulusコントローラーで利用するための静的プロパティdata-images-target、およびStimulusコントローラーで作成したアクションを発動させるためのdata-action属性を記述しておきます。

後ほどStimulusコントローラーにドラッグ&ドロップ時に必要なアクションdragover()dragleave()dropImages()を作成します。

【Stimulus】ドラッグ&ドロップ時のアクションを追記する

前回の記事で作成したimages_controller.jsに以下のように追記します。

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="images"
export default class extends Controller {
  /* 静的プロパティを定義(data-{controller}-target で指定したターゲット名) */
/************************ 静的プロパティに"drop"を追記 ************************/
  static targets = ["select", "preview", "image_box", "error", "drop"]
/**************************************************************************/

  /* 画像ファイルサイズの判定 */
  imageSizeOver(file){ // アップロードする画像ファイルサイズの上限(2MB)を超えたかどうか判定
    const fileSize = (file.size)/1000 // ファイルサイズ(KB)
    if(fileSize > 2000){
      return true // ファイルサイズが2MBを超えた場合はtrueを返す
    }else{
      return false
    }
  }
 
/***************************** ↓今回追記するコード ***************************/
  /* 画像ドラッグ時の処理(ドラッグ&ドロップ) */
  dragover(e) {
    e.preventDefault()
    // dragover したときに drop_area の色を変える
    this.dropTarget.classList.remove("bg-gray-50")
    this.dropTarget.classList.add("bg-gray-200")
  }

  dragleave(e) {
    e.preventDefault()
    // dragleave したときに drop_area の色を元に戻す
    this.dropTarget.classList.remove("bg-gray-200")
    this.dropTarget.classList.add("bg-gray-50")
  }

  dropImages(e){
    e.preventDefault()
    // drop した後に drop_area の色を元に戻す
    this.dropTarget.classList.remove("bg-gray-200")
    this.dropTarget.classList.add("bg-gray-50")

    this.errorTarget.textContent = ""
    const uploadedFilesCount = this.previewTarget.querySelectorAll(".image-box").length
    const files = e.dataTransfer.files // ドラッグ&ドロップした画像ファイルを読み込む
    if(files.length + uploadedFilesCount > 10){
      this.errorTarget.textContent = "画像アップロード上限は最大10枚です。"
    }else{
      for(const file of files){
        if(this.imageSizeOver(file)){
          this.errorTarget.textContent = "ファイルサイズの上限(1枚あたり5MB)を超えている画像はアップロードできません。"
        }else{
          this.uploadImage(file) // ファイルのアップロード
        }
      }
    }
    this.selectTarget.value = "" // 選択ファイルのリセット
  }
/**************************************************************************/

  /* 画像選択時の処理 */
  selectImages(){
    this.errorTarget.textContent = ""
    const uploadedFilesCount = this.previewTarget.querySelectorAll(".image-box").length
    const files = this.selectTargets[0].files
    if(files.length + uploadedFilesCount > 10){
      this.errorTarget.textContent = "画像アップロード上限は最大10枚です。"
    }else{
      for(const file of files){
        if(this.imageSizeOver(file)){
          this.errorTarget.textContent = "ファイルサイズの上限(1枚あたり5MB)を超えている画像はアップロードできません。"
        }else{
          this.uploadImage(file) // ファイルのアップロード
        }
      }
    }
    this.selectTarget.value = "" // 選択ファイルのリセット
  }

  /* 画像アップロード */
  uploadImage(file){
    const csrfToken = document.getElementsByName('csrf-token')[0].content // CSRFトークンを取得
    const formData = new FormData()
    formData.append("image", file) // formDataオブジェクトに画像ファイルをセット
    const options = {
      method: 'POST',
      headers: {
        'X-CSRF-Token': csrfToken
      },
      body: formData
    }
   /* fetchで画像ファイルをPostコントローラー(upload_imageアクション)に送信 */
    fetch("/posts/upload_image", options) 
      .then(response => response.json())
      .then(data => { // Postコントローラーからのレスポンス(blobデータ)
        this.previewImage(file, data.id) // 画像プレビューアクションにblobデータのidを受け渡す
      })
      .catch((error) => {
        console.error(error)
      })
  }

  /* 画像プレビュー */
  previewImage(file, id){
    const preview = this.previewTarget // プレビュー表示用の<div>要素
    const fileReader = new FileReader()
    const setAttr = (element, obj)=>{ // 属性設定用の関数
      Object.keys(obj).forEach((key)=>{
        element.setAttribute(key, obj[key])
      })
    }
    fileReader.readAsDataURL(file) // ファイルをData URIとして読み込む
    fileReader.onload = (function () { // ファイル読み込み時の処理
      const img = new Image()
      const imgBox = document.createElement("div")
      const imgInnerBox = document.createElement("div")
      const deleteBtn = document.createElement("a")
      const hiddenField = document.createElement("input")
      const imgBoxAttr = { // imgBoxに設定する属性
        "class" : "image-box inline-flex mx-1 mb-5",
        "data-controller" : "images",
        "data-images-target" : "image_box",
      }
      const imgInnerBoxAttr = { // imgInnerBoxに設定する属性
        "class" : "text-center"
      }
      const deleteBtnAttr = { // deleteBtnに設定する属性
        "class" : "link cursor-pointer",
        "data-action" : "click->images#deleteImage"
      }
      const hiddenFieldAttr = { // hiddenFieldに設定する属性
        "name" : "post[images][]",
        "style" : "none",
        "type" : "hidden",
        "value" : id, // 受け取ったidをセット
      }
      setAttr(imgBox, imgBoxAttr)
      setAttr(imgInnerBox, imgInnerBoxAttr)
      setAttr(deleteBtn, deleteBtnAttr)
      setAttr(hiddenField, hiddenFieldAttr)

      deleteBtn.textContent = "削除"

      imgBox.appendChild(imgInnerBox)
      imgInnerBox.appendChild(img)
      imgInnerBox.appendChild(deleteBtn)
      imgInnerBox.appendChild(hiddenField)
      img.src = this.result
      img.width = 100;

      preview.appendChild(imgBox) // プレビュー表示用の<div>要素の中にimgBox(プレビュー画像の要素)を入れる
    })
  }

  /* プレビュー画像の削除 */
  deleteImage(){
    this.image_boxTarget.remove()
  }

}

それぞれdragover()dragleave()dropImages()アクションを作成し、ファイルをドラッグ、リーブ(ドロップせずにポインタを外す)、ドロップした時のイベントを記述しています。

e.preventDefault()は、「ファイルをブラウザにドロップするとファイルの内容を別タブで画面に表示する」というブラウザのデフォルトの動きを阻止するために記述します。

(ドラッグ&ドロップの度にブラウザで画像が表示されると鬱陶しいですしね)

ちなみに、今回追記したコードはselectImages()アクションのコードと共通する部分が多く、うまいこと最適化できないものかと考えていましたが、、

面倒そうなのでこのままにしています(笑)

何かいい感じにコードを最適化できる方いたらアドバイスください。

以上です。

参考資料

Qiita
Stimulusを使ってドラッグ&ドロップでファイルアップロード - Qiita Rails で input[type=file] の入力欄にファイルをドラッグ&ドロップして選択する機能を追加しようとネットを調べてみたのですが、script 要素にべた書きの実装例しか見...
あわせて読みたい
inputをクリック、またはドラッグ&ドロップで、画像を選択し、ウェブページにアップロードさせる方... inputのtypeをfileに、acceptをimageにし、クリック、またはドラッグ&ドロップで画像を選択し、createObjectURLを用いて、imgタグのsrcに挿入
MDN Web Docs
HTMLElement: dragover イベント - Web API | MDN dragover イベントは、要素または選択されたテキストが、妥当なドロップターゲットの上にあるときに(数百ミリ秒間隔で)発生します。
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

愛知の34歳。無職で暇になり始めたプログラミング(Ruby on Rails)の忘備録をまとめたブログです。最近は別にやりたいことができたのでプログラミングほぼやっていません。気が向いたらまた再開するかも。僕の日常はメインブログの方で更新しています。

コメント

コメントする

目次