ActiveStorageを使用して複数枚の画像を投稿する
先ずはここを見てくださいRailsガイド、導入方法とか書いてあります。
S3に接続するときは注意が必要ですね。
そもそもActiveStorageは何がいいのか?
ActiveStorageの特徴としては画像をitemモデルとして扱えることでしょう。
どう言う事かと言うと、itemとimageが1:多の関係だとすると、テーブルを2個用意しないといけません。
そしてアソシエーションを組んで〜と言う処理をします。
削除や変更の時に処理が若干複雑になってしまいます。
そんな時、ActiveStorageならitemテーブルのみを対象に処理をすれば良いわけです。
ついでに、フォームの作成が比較的簡単になります。
今回の要件
- 複数同時選択による複数枚の画像投稿
- 複数回選択による複数枚の画像投稿
- プレビュー表示
- 投稿枚数管理
- プレビューの削除
###モデル
has_many_attached :images
ActiveStorageで複数画像を扱う為にモデルに記述
###コントローラー
def new
@item = Item.new
@category_parent = Category.where("ancestry is null")
end
def create
@item = Item.new(item_params)
if @item.save
redirect_to root_path
else
render :new
end
end
private
def item_params
params.require(:item).permit(:name, :text, :category_id, :condition_id, :deliverycost_id, :pref_id, :delivery_days_id, :price, images: []).merge(user_id: current_user.id, boughtflg_id:"1")
end
def item_paramsの images:[] がポイントです。
こうする事で、複数画像を受け取ることが出来ます。
###HAML
-# 画像部分
.sell-container__content
.sell-title
%h3.sell-title__text
出品画像
%span.sell-title__require
必須
.sell-container__content__max-sheet 最大10枚までアップロードできます
.sell-container__content__upload
.sell-container__content__upload__items
.sell-container__content__upload__items__box
%ul#output-box
ここにプレビューが入ります
%div#image-input{tabindex:"0"}
= f.label :images, for: "item_images0", class: 'sell-container__content__upload__items__box__label', data: {label_id: 0 } do
= f.file_field :images, multiple: true, class: "sell-container__content__upload__items__box__input", id: "item_images0", style: 'display: none;'
ここに新しinputが入ります
%pre
%i.fas.fa-camera.fa-lg
ドラッグアンドドロップ
またはクリックしてファイルをアップロード
.error-messages#error-image
ここにエラーメッセージが入ります
= f.file_fieldの multiple:true がポイントです
name属性がname="item[images][]"になっていると思います。
これにより、選択した画像を配列とし保持できるようになり、複数枚を同時に選択できるようになります。
###JS
$(document).on('turbolinks:load', function(){
$('#image-input').on('change', function(e){
// 画像が選択された時プレビュー表示、inputの親要素のdivをイベント元に指定
//ファイルオブジェクトを取得する
let files = e.target.files;
$.each(files, function(index, file) {
let reader = new FileReader();
//画像でない場合は処理終了
if(file.type.indexOf("image") < 0){
alert("画像ファイルを指定してください。");
return false;
}
//アップロードした画像を設定する
reader.onload = (function(file){
return function(e){
let imageLength = $('#output-box').children('li').length;
// 表示されているプレビューの数を数える
let labelLength = $("#image-input>label").eq(-1).data('label-id');
// #image-inputの子要素labelの中から最後の要素のカスタムデータidを取得
// プレビュー表示
$('#image-input').before(`<li class="preview-image" id="upload-image${labelLength}" data-image-id="${labelLength}">
<figure class="preview-image__figure">
<img src='${e.target.result}' title='${file.name}' >
</figure>
<div class="preview-image__button">
<a class="preview-image__button__edit">編集</a>
<a class="preview-image__button__delete" data-image-id="${labelLength}">削除</a>
</div>
</li>`);
$("#image-input>label").eq(-1).css('display','none');
// 入力されたlabelを見えなくする
if (imageLength < 9) {
// 表示されているプレビューが9以下なら、新たにinputを生成する
$("#image-input").append(`<label for="item_images${labelLength+1}" class="sell-container__content__upload__items__box__label" data-label-id="${labelLength+1}">
<input multiple="multiple" class="sell-container__content__upload__items__box__input" id="item_images${labelLength+1}" style="display: none;" type="file" name="item[images][]">
<i class="fas fa-camera fa-lg"></i>
</label>`);
};
};
})(file);
reader.readAsDataURL(file);
});
});
今回の1番の要所です
流れとしては
- 画像を選択し、inputに保持させる
- 保持された画像からファイルオブジェクトを取得する
- プレビューを表示する(イベント元のイベント元のカスタムデータIDの番号を付加する)
- 表示されているプレビューの数を数え、9以下なら次のinputを生成する(イベント元のカスタムデータIDの次の番号を付加する)
ポイント
-
プレビューとinputはカスタムデータIDの番号で紐づけます。
**data-image-id="${labelLength}"とdata-label-id="0"**の部分です。 -
input要素はidで区別します。
id="item_images${labelLength+1}" となっている部分です。
これは#image-inputの子要素labelの中から最後の要素のカスタムデータidを取得指定ます。
つまり、イベント元のカスタムデータIDの番号に+1しています。 -
labelとinputは、labelのfor属性とinputのidで紐づける。
**for="item_images${labelLength+1}"とid="item_images${labelLength+1}"**の部分です。
ul要素の下のli要素がプレビューです。
その下のdiv要素の下がinputが囲われたlabelです。
プレビューを削除する
//削除ボタンが押された時
$(document).on('click', '.preview-image__button__delete', function(){
let targetImageId = $(this).data('image-id');
// イベント元のカスタムデータ属性の値を取得
$(`#upload-image${targetImageId}`).remove();
//プレビューを削除
$(`[for=item_images${targetImageId}]`).remove();
//削除したプレビューに関連したinputを削除
let imageLength = $('#output-box').children('li').length;
// 表示されているプレビューの数を数える
if (imageLength ==9) {
let labelLength = $("#image-input>label").eq(-1).data('label-id');
// 表示されているプレビューが9枚なら,#image-inputの子要素labelの中から最後の要素のカスタムデータidを取得
$("#image-input").append(`<label for="item_images${labelLength+1}" class="sell-container__content__upload__items__box__label" data-label-id="${labelLength+1}">
<input multiple="multiple" class="sell-container__content__upload__items__box__input" id="item_images${labelLength+1}" style="display: none;" type="file" name="item[images][]">
<i class="fas fa-camera fa-lg"></i>
</label>`);
};
});
バリデーション
validate :images_presence
#バリデーションを呼び出す
def images_presence
if images.attached?
# inputに保持されているimagesがあるかを確認
if images.length > 10
errors.add(:image, '10枚まで投稿できます')
end
else
errors.add(:image, '画像がありません')
end
end
ActiveStorageは基本のバリデーションが使えません。
なので自分で作りました。
最後に
後はCSSでデコレーションすればOKですね。
ただし、一つ問題があります。
複数同時選択を行い、プレビューを削除した時に不具合が発生する事です。
と言うのも、同時選択をすると一つのinputに複数の画像の情報が保持されるます。
そしてinputから情報を全て抜き取る事は出来ません。
つまり、あるinputを削除すると複数画像のデータが消えてしまう・・・と言う事です。
対策としては
-
同時選択を出来ないようにする
この場合、name属性にも手を加える必要があります。 -
先にDBに一旦保存してしまう
ちょっと難しそう
それらは今後試すことにします。
今回は以上です