12
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Rails】画像の複数登録+プレビュー表示

Last updated at Posted at 2020-05-07

メルカリのクローンサイト作成にあたり、商品出品ページを実装しました。
今回は、画像登録に関する備忘録です。

ゴール

Alt text

画像を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内の先頭に追加

コード

画像登録の準備

テーブルの作成

item_imgsテーブル
 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が表示されなくなった時は、子モデルのインスタンスを生成できていない可能性がある。

item.rb
 has_many :item_imgs, dependent: :destroy
 accepts_nested_attributes_for :item_imgs, 
 allow_destroy: true
item.rb
 mount_uploader :src, ImageUploader
 belongs_to :item, optional: true

 validates :src, presence: true

コントローラー

items_controller.rb
  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; にしておく。

haml
 .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タグには削除ボタンもセットで作成。

items.js
 //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枚分)。

items.scss
  .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タグに格納して、プレビュー表示。

items.js
$(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タグを追加

items.js
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; にする

items.js
$(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枚以上の場合、追加ができない。
なので、出品編集ページに移行した際のアクションを作成。

items.js
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を使用。

items.js
//領域に入ったとき
$(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) {
    :
   //上記のファイル選択による画像アップロードのコードと同じ
    :
  }
});

ドラッグ&ドロップでアップロードした時の動き↓
Alt text

12
36
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?