以前、Active Storageで複数枚画像のプレビュー表示、およびアップロードする方法についてまとめました。(Stimulusで実装)
ただ、前回の実装ではドラッグ&ドロップの実装はしておらず。
今どきドラッグ&ドロップは当たり前だし、僕も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に上げておきました。
それでは進めていきます。
画像のドラッグ&ドロップエリアを作成する
画像を投稿・編集、および表示するためのビュー_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()
アクションのコードと共通する部分が多く、うまいこと最適化できないものかと考えていましたが、、
面倒そうなのでこのままにしています(笑)
何かいい感じにコードを最適化できる方いたらアドバイスください。
以上です。
コメント