メルカリのクローンサイト作成にあたり、商品出品ページを実装しました。
今回は、画像登録に関する備忘録です。
ゴール
・ 画像をDBに登録する前にプレビュー表示
・ 画像は最大10枚まで登録可能
・ 1〜5枚目までは上段、6〜10枚目までは下段に表示させる
・ 画像は最低1枚以上登録しないと商品出品できない
・ プレビュー画像を1枚ずつ削除
・ DBに保存済み画像があればページアップロード時に表示させる
実装の考え方
- 画像を1枚アップロードするごとに、新たにinputタグとimgタグを生成
- プレビューとinputタグに同じ番号を振って管理する。
- プレビュー画像と削除ボタンをセットで実装し、削除ボタンをクリックしたら、その番号を持つinputタグを削除する
- DBに保存済み画像にも、削除ボタン、番号を振ったプレビューの表示を行う。
※imgタグの管理番号の整理方法 :
属性名 | 用途 |
---|---|
index | DBに未保存の画像 |
data-name | DBに保存済みの画像 |
data-index | 全画像(DBに保存+未保存) |
※inputタグの管理番号の整理方法 : |
属性名 | 用途 |
---|---|
index | DBに未保存の画像 |
data-index | プレビュー数(imgタグのdata-indexと同義) |
画像の追加機能の実装
処理の流れ
⓪ labelの中にinputタグを配置しておく
① labelがクリックされ、画像ファイルが追加される(イベント発火)
② プレビュー表示のために、imgタグに画像のurlを追加し、HTMLに追加
③ inputタグのclass名を変更し、label内の一番後ろに移動(※識別に使用)
④ 新しいinputタグをlabel内の先頭に追加
コード
画像登録の準備
テーブルの作成
create_table :item_imgs do |t|
t.string :src, null:false
t.references :item, null: false, foreign_key: true
t.timestamps
end
モデル
関連のあるモデルをまとめて操作するために使われるaccepts_nested_attributes_for
を使用。
※ fields_forを利用する際、親モデルに書く必要がある。
※ ビューにfields_forが表示されなくなった時は、子モデルのインスタンスを生成できていない可能性がある。
has_many :item_imgs, dependent: :destroy
accepts_nested_attributes_for :item_imgs,
allow_destroy: true
mount_uploader :src, ImageUploader
belongs_to :item, optional: true
validates :src, presence: true
コントローラー
def new
@item = Item.new
@item.item_imgs.build
end
def create
@item = Item.new(item_params)
if @item.save
redirect_to item_path(@item)
else
render :new
flash.now[:alert] = "商品出品に失敗しました"
end
end
def edit
end
def update
if @item.update(item_params)
redirect_to item_path(@item)
else
render :edit
flash.now[:alert] = '商品情報の更新に失敗しました'
end
end
def destroy
if @item.destroy
redirect_to :root
else
render :show
end
end
private
def item_params
params.require(:item).permit(
:name, :item_condition_id, :introduction, :price, :prefecture_code, :trading_status, :postage_payer_id, :size_id, :preparation_day_id, :postage_type_id, :category_id,
item_imgs_attributes: [:src, :_destroy, :id]
).merge(seller_id: current_user.id, trading_status: 0)
end
ビュー
・ 2行目で、form_for
でitemモデルを指定している。画像は紐づいている別モデル(item_imgモデル)に保存したいので、fields_for
を使用(5行目)。
・ inputタグは上段に設置。
・ 下段は display:none; にしておく。
.item_input
= form_for @item, local: true do |f|
.item_input__body
.up-image
= f.fields_for :item_imgs do |image|
-# 上段
.up-image__group
-# 上段のプレビュー格納エリア
.previews
-# DBに保存済みの画像があれば、1〜5枚をプレビュー表示
- if @item.persisted?
- @item.item_imgs.each.with_index(1) do |img, i|
- next if i >= 6
.preview.preview_saved{{data:{name: i}},{data:{index: i}}}
.img_box
= image_tag img.src.url, data: {index: i}, class: "preview_image"
.preview_btn
削除
-# 上段のinputタグ格納label
%label.item_imgs
.up-image__group__dropbox{data: {index: 1}}
= image.file_field :src, class: "item_imgs__default", id: "up_img_last", type: 'file', multiple: true, accept: "image/*"
-# DBに保存済みの画像があれば、チェックボックスを設置
- if @item.persisted?
= f.fields_for :item_imgs do |image|
= image.check_box :_destroy, data:{index: image.index+1}, class: 'hidden-destroy'
-# 下段
.under_group
.up-image__group_2nd_row
-# 下段のプレビュー格納エリア
.previews_2nd_row
-# DBに保存済みの画像があれば、6〜10枚をプレビュー表示
- if @item.persisted?
- @item.item_imgs.each.with_index(1) do |img, i|
- next if i <= 5
.preview.preview_saved{{data:{name: i}},{data:{index: i}}}
.img_box
= image_tag img.src.url, data: {index: i}, class: "preview_image"
.preview_btn
削除
-# 下段のinputタグ格納label
%label.item_imgs_2nd_row
画像の追加イベント
追加用のHTMLを作成。
※ 削除機能の実装用にimgタグには削除ボタンもセットで作成。
//inputタグ
let nextInput = (num, index)=> {
let html = `<div class="up-image__group__dropbox" data-index="${num}" index="${index}">
<input class="item_imgs__default"
type="file"
multiple= "multiple"
accept="image/*"></input></div>`;
return html;
}
//プレビュー用のimgタグ
let previewImages = (src)=> {
let html = `<div class="preview preview_unsave">
<div class="img_box">
<img src="${src}" class="preview_image"></div>
<div class="preview_btn">削除</div></div>`;
return html;
}
プレビューエリアとinputエリアも含む全体の大きさを指定しておくことで、画像が追加されると自動でinputエリアを調整してくれる(※ 全体の横幅=プレビュー画像×5枚分)。
.item_imgs {
height: 150px;
width: 100%;
input[type='file'] {
display: none;
}
}
.previews {
display: flex;
height: 190px;
}
.preview{
height: 150px;
width: 120px;
.img_box{
height: 118px;
width: 100%;
display: flex;
align-items: center;
.preview_image{
max-width: 120px;
max-height: 118px;
margin: 0 auto;
}
}
}
次に、画像アップロードされたら、画像データを取得し、imgタグに格納して、プレビュー表示。
$(document).on('change','input[type= "file"]', function(e) {
let reader = new FileReader(); //画像を読み込む
let file = e.target.files[0]; //inputから1つ目のfileを取得
reader.readAsDataURL(file); //画像ファイルのURLを取得
//画像読み込みが完了したらプレビュー表示
reader.onload = function(e) {
:
}
});
読み込み後のアクション概要
【プレビュー表示】
・ 既に読み込まれた画像が4枚以下 : 上段にimgタグを追加
・ 既に読み込まれた画像が5枚以上 : 下段にimgタグを追加
imgタグを追加後、プレビュー画像を数え、
・ プレビュー画像が5枚なら、上段のlabelを display:none; 、上段の
プレビューエリアとlabelを display:block; にする
・ プレビュー画像が10枚なら、下段のlabelを display:none; にする
【inputタグ】
データの格納有無を識別するために、データが入ったinputタグのclass名を変更し、
・ 既に読み込まれた画像が5枚以下 : 上段のlabelの最後尾に移動させる
・ 既に読み込まれた画像が6枚以上 : 下段のlabelの最後尾に移動させる
次の画像アップロードの準備
・ 既に読み込まれた画像が4枚以下 : 上段のlabelの先頭に新たなinputタグを追加
・ 既に読み込まれた画像が5枚以上 : 下段のlabelの先頭に新たなinputタグを追加
reader.onload = function(e) {
//imgタグ
if ($('.preview').length <= 4) {
$('.previews').append(previewImages(e.target.result));
} else {
$('.previews_2nd_row').append(previewImages(e.target.result));
}
let preview_count = $('.preview').length;
let preview_unsave_count = $('.preview_unsave').length;
let preview_save_count = $('.preview_saved').length;
let preview_saved_count = $('.hidden-destroy').length;
//データの入ったinputタグ
if (preview_count <= 5) {
$('.up-image__group__dropbox').removeClass('up-image__group__dropbox').addClass('image-preview').appendTo('.item_imgs');
} else {
$('.up-image__group__dropbox').removeClass('up-image__group__dropbox').addClass('image-preview').appendTo('.item_imgs_2nd_row');
}
//新しいinputタグを追加
if (preview_count <= 4) {
$('.item_imgs').prepend(nextInput(preview_count + 1, preview_unsave_count + 1));
} else {
$('.item_imgs_2nd_row').prepend(nextInput(preview_count + 1, preview_unsave_count + 1));
}
//プレビュー画像が5枚になったら1段目inputを消し、2段目にinputを表示
if (preview_total_num == 5) {
$('.item_imgs').css('display', 'none');
$('.under_group').css('display', 'block');
$('.item_imgs_2nd_row').css('display', 'block');
}
//プレビュー画像が10枚になったら2段目inputを消す
if (preview_total_num == 10) {
$('.item_imgs_2nd_row').css('display', 'none');
}
//識別のための管理番号をつけ直す
$('.preview').each(function(i) {
$(this).attr('data-index', (i+1));
});
$('.preview_unsave').each(function(i) {
$(this).attr('index', (i+1));
});
$('.image-preview').each(function(i) {
$(this).attr('index', (i+1));
$(this).attr('data-index', (preview_save_count+i+1));
$(this).children().attr('name', "item[item_imgs_attributes][" + (preview_saved_count+i) + "][src]");
$(this).children().attr('data-index', (i+1));
});
}
画像の削除機能の実装
処理の流れ
① 削除ボタンがクリックされたら、imgタグの管理番号を取得し、そのimgタグを削除
② その管理番号から、保存済み画像なら、該当するチェックボックスにチェックを入れる。未保存画像なら該当するinputタグを削除。
コード
削除後のアクション概要
プレビュー画像の総数が4枚になったら、
・ 上段のinputエリアを display:block; にして、新しいinputタグを追加する
・ 下段のinputエリアを空にして display:none; にする
プレビュー画像の総数が5〜9枚、かつ、削除したプレビュー画像が上段なら、
・ 下段のプレビュー画像とinputタグを1枚分、上段に移動させる
プレビュー画像の総数が9枚になったら、
・ 下段のinputエリアを display:block; にする
$(document).on("click",'.preview_btn', function() {
let targetIndex = $(this).parent().data("name");
$(this).parent().remove();
if (targetIndex >= 0) {
let hiddenCheck = $(`input[data-index="${targetIndex}"].hidden-destroy`);
hiddenCheck.prop('checked', true)
}
let preview_num = $(this).parent().attr('index');
let preview_total_num = $(this).parent().attr('data-index');
let preview_count = $('.preview').length;
let preview_unsave_count = $('.preview_unsave').length;
let preview_save_count = $('.preview_saved').length;
let preview_saved_count = $('.hidden-destroy').length;
if (preview_num >= 0) {
$('.image-preview[index ='+preview_num+']').remove();
}
//管理番号をつけ直す
$('.preview').each(function(i) {
$(this).attr('data-index', (i+1));
});
$('.preview_unsave').each(function(i) {
$(this).attr('index', (i+1));
});
$('.image-preview').each(function(i) {
$(this).attr('index', (i+1));
$(this).attr('data-index', (preview_save_count+i+1));
$(this).children().attr('name', "item[item_imgs_attributes][" + (preview_saved_count+i+1) + "][src]");
$(this).children().attr('data-index', (i+1));
});
if (preview_count == 4 ) {
$('.item_imgs_2nd_row').find('.up-image__group__dropbox').remove();
$('.item_imgs').prepend(nextInput(preview_count + 1, preview_unsave_count + 1));
$('.item_imgs_2nd_row').css('display', 'none');
$('.item_imgs').css('display', 'block');
$('.image_text_message').css('display', 'none');
} else if (preview_count >=5 && preview_count <=8 && preview_total_num <= 5) {
$('.preview[data-index ='+5+']').appendTo('.previews');
$('.image-preview[data-index ='+5+']').appendTo('.item_imgs');
} else if (preview_count == 9) {
$('.item_imgs_2nd_row').css('display', 'block');
if (preview_total_num <= 5) {
$('.preview[data-index ='+5+']').appendTo('.previews');
$('.preview[data-index ='+5+']').attr('index', (5));
$('.image-preview[data-index ='+5+']').appendTo('.item_imgs');
}
}
});
ページ更新に関する実装
上記のコードでは、inputタグを上段に設置しているため、DBに保存済みの画像が5枚以上の場合、追加ができない。
なので、出品編集ページに移行した際のアクションを作成。
window.onload = function () {
//画像削除用のチェックボックス
$('.hidden-destroy').hide();
let image_num = $('.preview').length;
//DBに保存済みの画像が5枚以上の場合
if (image_num >= 5) {
$('.item_imgs').css('display', 'none');
$('.under_group').css('display', 'block');
$('.item_imgs_2nd_row').css('display', 'block');
$('.item_imgs').find('.up-image__group__dropbox').remove();
$('.item_imgs_2nd_row').prepend(nextInput(image_num+1));
}
//DBに保存済みの画像が10枚の場合
if (image_num == 10) {
$('.item_imgs_2nd_row').css('display', 'none');
}
}
ドラッグ&ドロップによるアップロード
最後に、ドラッグ&ドロップでもアップロード可能にします。
JSにコードを追加だけで実装可能です。
ドラッグ&ドロップされたら、その画像データをinputタグとimgタグに渡してあげるイメージです。
※ jQueryでは、ドラッグ&ドロップイベントはdataTransfer
で受け取ることができないので、originalEvent
を使用。
//領域に入ったとき
$(document).on('dragenter', ".item_imgs, .item_imgs_2nd_row", function(){
$(".item_imgs, .item_imgs_2nd_row").css('border', '1px solid greenyellow');
});
//領域から出たとき
$(document).on('dragleave', ".item_imgs, .item_imgs_2nd_row", function(){
$(".item_imgs, .item_imgs_2nd_row").css('border', '1px dashed rgb(204, 204, 204)');
});
//領域上にあるとき
$(document).on('dragover', ".item_imgs, .item_imgs_2nd_row", function(e){
e.preventDefault();
});
// ドロップした時
$(document).on('drop', ".item_imgs, .item_imgs_2nd_row", function(e){
e.preventDefault();
$(".item_imgs, .item_imgs_2nd_row").css('border', '1px dashed rgb(204, 204, 204)');
let file = e.originalEvent.dataTransfer.files[0];
let reader = new FileReader();
reader.readAsDataURL(file);
$(".up-image__group__dropbox").children('.item_imgs__default')[0].files = e.originalEvent.dataTransfer.files;
// ファイル形式を画像だけに制限
if (!file.type.match('image.*')) {
alert('画像を選択してください');
return;
}
reader.onload = function(e) {
:
//上記のファイル選択による画像アップロードのコードと同じ
:
}
});