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

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

# ゴール
![Alt text](https://gyazo.com/898339aeaf5b35bc135f8494dce8f0cc/raw)

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

## コード
### 画像登録の準備
**テーブルの作成**

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

```ruby:item.rb
 has_many :item_imgs, dependent: :destroy
 accepts_nested_attributes_for :item_imgs, 
 allow_destroy: true
```

```ruby:item.rb
 mount_uploader :src, ImageUploader
 belongs_to :item, optional: true

 validates :src, presence: true
```
**コントローラー**

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

```JS: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枚分)。

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

```JS: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タグを追加

```JS: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));
  }
  //プレビュー画像が５枚になったら１段目inputを消し、２段目に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; にする

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

```JS: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`を使用。

```JS: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](https://gyazo.com/3cdeeb26a71080520eacc7ad72d552ec/raw)