83
128

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.

画像の複数枚投稿と編集とプレビューと私

Last updated at Posted at 2020-01-14

##以前の記事にて
初投稿につき緊張ながら投稿したのをよく覚えています。
たくさん見てくださってありがとうございました。
画像の複数投稿??プレビュー表示??え??

しかし......

「投稿のみやん。editは?はよ。」と多数ご指摘をいただきました。

しかし.......

何の成果もあげられませんでした。

編集することができませんでした。

藁にもすがる思いでメンターさんにコードを見てもらい、アドバイスを仰ぎましたが、

「僕こんな実装したことないし、こんなやり方見たことない。
メンテナンス性も悪いし可読性もよろしくない。
他に効率のいい方法あるから.....やり直そうか」

と、ありがたくも残酷なご指摘をいただきました。

参考にしてくださった方々ごめんなさい。
メンテナンス性が悪く可読性もよくないお粗末なコードを書いてしまいました......

なので今回改良版をやります。意地で。

##仕様

  1. 画像は5枚投稿できる
  2. 投稿した画像は1枚ずつプレビューされる
  3. 5枚目を投稿すると投稿欄が消える
  4. ドラッグ&ドロップは非実装
  5. 削除を押すとプレビューが消える

##前回の反省
前回は新規投稿に注力しすぎて編集する時のことを考えられていなかったことが敗因でした。
新規投稿が完成した時点でやりきった感がありました。
スプリントレビューで「編集は?」と言われて「Oh.......」ってなったものの対応できず、
「1枚画像が投稿できる状態にする」というレビューOKの最低ラインに合わせるべくJSファイルを消去しました。悲しい。

「後のことを考えて実装する」というもっとも重要な設計思想が抜けていました。
大いに反省するきっかけとなったのでよしとしましょう。

しかし僕は諦めが悪いので、
今回は編集機能の実装を前提として考えていきたいと思います。
ではLET'S GO.

##編集するには....
まず、編集ページへアクセスすると、
登録済みの写真についてはプレビューが表示されている状態にしなければいけません。
Image from Gyazo

前回は、画像登録の際にdataTransferというデータの箱を使って、1つのfile_fieldにデータを追加していく形で複数投稿を実装していました。
しかし、いざeditを実装するとなった際に、dataTransferにデータを追加することができず断念する結果となりました......
1つのfile_fieldを酷使する実装にはやはり無理があったようです。無理させてごめんねfile_field。

そこで今回は別々のidを持ったfile_fieldを5つ作成し、そのそれぞれにデータを入れていく形で実装することにしました。
これなら画像の編集も削除もできそうです。
では画像投稿機能から実装していきましょう。

##プレビュー表示と削除
前回も利用したsample_appを使用します。

items/new.haml
.main
  %section.main__block
    = form_with model:@item, local:true do |f|
      %h2.sell__block__head
        商品の情報を入力
      .sell__block__form
        .sell__block__form__upload
          %h3.sell__block__form__upload__head
            出品画像
            %span.require 必須
          %p 最大5枚までアップロードできます
          .post__drop__box__container
            .prev-content
            .label-content
              %label{for: "item_images_attributes_0_image", class: "abel-box", id: "label-box--0"}
                %pre.label-box__text-visible クリックしてファイルをアップロード
            .hidden-content
              = f.fields_for :images do |i|
                = i.file_field :image, class: "hidden-field"
                %input{class:"hidden-field", type: "file", name: "item[images_attributes][1][image]", id: "item_images_attributes_1_image" }
                %input{class:"hidden-field", type: "file", name: "item[images_attributes][2][image]", id: "item_images_attributes_2_image" }
                %input{class:"hidden-field", type: "file", name: "item[images_attributes][3][image]", id: "item_images_attributes_3_image" }
                %input{class:"hidden-field", type: "file", name: "item[images_attributes][4][image]", id: "item_images_attributes_4_image" }
        .sell__block__form__name
          .form-group__name
            %label
              商品名
              %span.require 必須
            %div
              = f.text_field :name, placeholder:"商品名(必須 40文字まで)",class: "form__group__name"

        .sell__block__form__btn
          %div
            = f.submit "出品する",class: "btn-default__btn-red"
items.scss
//投稿欄以外のCSSは省略します

//image投稿欄のCSS
.post__drop__box__container{
  display: block;
  margin: 16px auto 0;
  display: flex;
  flex-wrap: wrap;

  //プレビュー表示欄のCSS
  .prev-content {
    display: flex;
    .preview-box {
      height: 162px;
      width: 112px;
      margin: 0 15px 10px 0;
      .upper-box {
        height: 112px;
        width: 100%;
        img{
          width: 112px;
          height: 112px;
        }
      }
      .lower-box {
        display: flex;
        text-align: center;
        .update-box {
          color: #00b0ff;
          width: 50%;
          height: 50px;
          line-height: 50px;
          border: 1px solid #eee;
          background: #f5f5f5;
          cursor: pointer;
        }
        .delete-box {
          color: #00b0ff;
          width: 50%;
          height: 50px;
          line-height: 50px;
          border: 1px solid #eee;
          background: #f5f5f5;
          cursor: pointer;
        }
      }
    }
  }

  //投稿クリックエリアのCSS
  .label-content{
    margin-bottom: 10px;
    width: 620px;
    .label-box {
      display: block;
      border: 1px dashed #ccc;
      position: relative;
      background: #f5f5f5;
      width: 100%;
      height: 162px;
      cursor: pointer;
      &__text-visible {
        position: absolute;
        top: 50%;
        left: 16px;
        right: 16px;
        text-align: center;
        font-size: 14px;
        line-height: 1.5;
        font-weight: bold;
        -webkit-transform: translate(0, -50%);
        transform: translate(0, -50%);
        pointer-events: none;
        white-space: pre-wrap;
        word-wrap: break-word;
      }
    }
  }

  //file_fieldのcss
  .hidden-content {
    .hidden-field {
      display: none;
    }
    .hidden-checkbox {
      display: none;
    }
  }
}

今回は画像を5枚投稿するので、file_fieldを5つ作りました。
全てdisplay: none;で隠しています。
スクリーンショット 2020-01-14 23.36.14.png
こんな感じ。
スクリーンショット 2020-01-14 23.43.16.png

5つのfile_fieldには別のidが振ってあります。
したがって、file_fieldに画像が入るたびにlabel側のforを変更していけば1枚ずつ複数の画像を投稿することができますね。

では、JSで操作してきましょう。

item_new.js
$(document).on('turbolinks:load', function(){
  $(function(){

    //プレビューのhtmlを定義
    function buildHTML(count) {
      var html = `<div class="preview-box" id="preview-box__${count}">
                    <div class="upper-box">
                      <img src="" alt="preview">
                    </div>
                    <div class="lower-box">
                      <div class="update-box">
                        <label class="edit_btn">編集</label>
                      </div>
                      <div class="delete-box" id="delete_btn_${count}">
                        <span>削除</span>
                      </div>
                    </div>
                  </div>`
      return html;
    }

    // ラベルのwidth操作
    function setLabel() {
      //プレビューボックスのwidthを取得し、maxから引くことでラベルのwidthを決定
      var prevContent = $('.label-content').prev();
      labelWidth = (620 - $(prevContent).css('width').replace(/[^0-9]/g, ''));
      $('.label-content').css('width', labelWidth);
    }

    // プレビューの追加
    $(document).on('change', '.hidden-field', function() {
      setLabel();
      //hidden-fieldのidの数値のみ取得
      var id = $(this).attr('id').replace(/[^0-9]/g, '');
      //labelボックスのidとforを更新
      $('.label-box').attr({id: `label-box--${id}`,for: `item_images_attributes_${id}_image`});
      //選択したfileのオブジェクトを取得
      var file = this.files[0];
      var reader = new FileReader();
      //readAsDataURLで指定したFileオブジェクトを読み込む
      reader.readAsDataURL(file);
      //読み込み時に発火するイベント
      reader.onload = function() {
        var image = this.result;
        //プレビューが元々なかった場合はhtmlを追加
        if ($(`#preview-box__${id}`).length == 0) {
          var count = $('.preview-box').length;
          var html = buildHTML(id);
          //ラベルの直前のプレビュー群にプレビューを追加
          var prevContent = $('.label-content').prev();
          $(prevContent).append(html);
        }
        //イメージを追加
        $(`#preview-box__${id} img`).attr('src', `${image}`);
        var count = $('.preview-box').length;
        //プレビューが5個あったらラベルを隠す 
        if (count == 5) { 
          $('.label-content').hide();
        }
        
        //ラベルのwidth操作
        setLabel();
        //ラベルのidとforの値を変更
        if(count < 5){
          //プレビューの数でラベルのオプションを更新する
          $('.label-box').attr({id: `label-box--${count}`,for: `item_images_attributes_${count}_image`});
        }
      }
    });

    // 画像の削除
    $(document).on('click', '.delete-box', function() {
      var count = $('.preview-box').length;
      setLabel(count);
      //item_images_attributes_${id}_image から${id}に入った数字のみを抽出
      var id = $(this).attr('id').replace(/[^0-9]/g, '');
      //取得したidに該当するプレビューを削除
      $(`#preview-box__${id}`).remove();
      console.log("new")
      //フォームの中身を削除 
      $(`#item_images_attributes_${id}_image`).val("");

      //削除時のラベル操作
      var count = $('.preview-box').length;
      //5個めが消されたらラベルを表示
      if (count == 4) {
        $('.label-content').show();
      }
      setLabel(count);

      if(id < 5){
        //削除された際に、空っぽになったfile_fieldをもう一度入力可能にする
        $('.label-box').attr({id: `label-box--${id}`,for: `item_images_attributes_${id}_image`});
      }
    });
  });
})

プレビュー表示の流れとしては、
(1) id = 0 〜 5のfile_fieldと、for = 0のlabelがある
(2) id = 0のfile_fieldに画像を放り込む
(3) labelのforがJS側で for = 1に変更される
(4) label部をクリックするとid = 1のfile_fieldに画像が入るようになる
(5) 繰り返し 5枚投稿するとlabelが消える
って感じです。
GIFで見るとわかりやすいかも。
Image from Gyazo
......7秒は短い。
しかし、1枚目を投稿したあとでfile_fieldの判定が2つ目に移動しているのがわかりますね。

また、プレビュー削除の流れとしては
(1) プレビューが2つある。それぞれが格納されているfile_fieldのidは0, 1。
(2) id = 0の方の削除ボタンを押す
(3) プレビューが消え、id = 0のfile_fieldの中身が消える
(4) labelのforオプションを0として更新
(5) id = 0のfile_fieldが再度入力可能になる
って感じで実装しています。
GIFで見ると......
Image from Gyazo
削除したfile_fieldが再度入力可能になっているのがわかりますね。
プレビュー表示側ではプレビュー数をもとにlabelのforオプションを更新するよう設定しているので、この後ファイルを追加する際にはちゃんと次の空のfile_fieldに判定が移動する実装になっています。

controller側はというと......

items_controller.rb
class ItemsController < ApplicationController

  def new
    @item = Item.new
    @images = @item.images.build
  end

  def create
    @item = Item.new(item_params)
    @item.save
  end

  private

    def item_params
      params.require(:item).permit(
        :name,
        [images_attributes: [:image]])
    end
end

こんな感じです。サンプルなのでめちゃくちゃシンプルに書いてます。お許しを。
また、DBのカラム名ですが
imagesテーブルに関しては「image」カラムにファイル名が保存される形で実装しています。

これで画像の複数投稿ができるようになりました。良かったね。

##編集機能
さて今回の本題です。
編集ページへのアクセス時にどうやってプレビューを出すか......ですが、
わかりやすくeach文で表示することにしました。

edit.haml
.main
  %section.main__block
    = form_with model:@item, local:true do |f|
      %h2.sell__block__head
        商品の情報を入力
      .sell__block__form
        .sell__block__form__upload
          %h3.sell__block__form__upload__head
            出品画像
            %span.require 必須
          %p 最大5枚までアップロードできます
          .post__drop__box__container
            .prev-content

              //JSで挿入したhtmlと同じ形 each文でのプレビュー表示
              - @item.images.each do |image|
                .preview-box
                  .upper-box
                    = image_tag image.image.url, width: "112", height: "112", alt: "preview"
                  .lower-box
                    .update-box
                      %label.edit-btn 編集
                    .delete-box
                      .delete-btn
                        %span 削除
            .label-content

              //プレビューの数に合わせてforオプションを指定
              = f.label :"images_attributes_#{@item.images.length}_image", class: "label-box", id: "label-box--#{@item.images.length}" do
                %pre.label-box__text-visible クリックしてファイルをアップロード
            .hidden-content
              = f.fields_for :images do |i|

                //プレビューが出ている分のfile_fieldとdelete用のチェックボックスを設置
                = i.file_field :image, class: "hidden-field"
                = i.check_box:_destroy, class: "hidden-checkbox"

                //5つのfile_fieldを準備するに当たって、足りない分を表示
              - @item.images.length.upto(4) do |i|
                %input{name: "item[images_attributes][#{i}][image]", id: "item_images_attributes_#{i}_image", class:"hidden-field", type:"file"}
        .sell__block__form__name
          .form-group__name
            %label
              商品名
              %span.require 必須
            %div
              = f.text_field :name, placeholder:"商品名(必須 40文字まで)",class: "form__group__name"

        .sell__block__form__btn
          %div
            = f.submit "出品する",class: "btn-default__btn-red"
スクリーンショット 2020-01-15 1.45.42.png

file_field関連のdisplay: none;を外すとこんな感じになります。
スクリーンショット 2020-01-15 1.46.53.png
ちょっとわかりにくいですが、現在投稿済みのプレビューに対応するfile_fieldの横に削除用のチェックボックスが追加されています。

編集の流れとしては、
(1) id = 0, 1のfile_fieldが投稿済みのものと対応しているものとする
(2) id = 0の削除ボタンを押す
(3) プレビューが削除されるとともに、id = 0のfile_fieldに対応する削除用チェックボックスにチェックが入る
(4) 投稿時と同様、labelをクリックすると削除済みのid = 0のfile_fieldがアクティブになる
(5) 再度id = 0に新しい画像が入ったら、削除用チェックボックスのチェックを外す

こんな感じで実装していきたいと思います。
(4)を見るに、「item_new.js」に追記、場合分けする形でeditも一緒に実装できそうですね。

完成コードはこちら。

item_new.js
$(document).on('turbolinks:load', function(){
  $(function(){

    //プレビューのhtmlを定義
    function buildHTML(count) {
      var html = `<div class="preview-box" id="preview-box__${count}">
                    <div class="upper-box">
                      <img src="" alt="preview">
                    </div>
                    <div class="lower-box">
                      <div class="update-box">
                        <label class="edit_btn">編集</label>
                      </div>
                      <div class="delete-box" id="delete_btn_${count}">
                        <span>削除</span>
                      </div>
                    </div>
                  </div>`
      return html;
    }

    // 投稿編集時
    //items/:i/editページへリンクした際のアクション=======================================
    if (window.location.href.match(/\/items\/\d+\/edit/)){
      //登録済み画像のプレビュー表示欄の要素を取得する
      var prevContent = $('.label-content').prev();
      labelWidth = (620 - $(prevContent).css('width').replace(/[^0-9]/g, ''));
      $('.label-content').css('width', labelWidth);
      //プレビューにidを追加
      $('.preview-box').each(function(index, box){
        $(box).attr('id', `preview-box__${index}`);
      })
      //削除ボタンにidを追加
      $('.delete-box').each(function(index, box){
        $(box).attr('id', `delete_btn_${index}`);
      })
      var count = $('.preview-box').length;
      //プレビューが5あるときは、投稿ボックスを消しておく
      if (count == 5) {
        $('.label-content').hide();
      }
    }
    //=============================================================================

    // ラベルのwidth操作
    function setLabel() {
      //プレビューボックスのwidthを取得し、maxから引くことでラベルのwidthを決定
      var prevContent = $('.label-content').prev();
      labelWidth = (620 - $(prevContent).css('width').replace(/[^0-9]/g, ''));
      $('.label-content').css('width', labelWidth);
    }

    // プレビューの追加
    $(document).on('change', '.hidden-field', function() {
      setLabel();
      //hidden-fieldのidの数値のみ取得
      var id = $(this).attr('id').replace(/[^0-9]/g, '');
      //labelボックスのidとforを更新
      $('.label-box').attr({id: `label-box--${id}`,for: `item_images_attributes_${id}_image`});
      //選択したfileのオブジェクトを取得
      var file = this.files[0];
      var reader = new FileReader();
      //readAsDataURLで指定したFileオブジェクトを読み込む
      reader.readAsDataURL(file);
      //読み込み時に発火するイベント
      reader.onload = function() {
        var image = this.result;
        //プレビューが元々なかった場合はhtmlを追加
        if ($(`#preview-box__${id}`).length == 0) {
          var count = $('.preview-box').length;
          var html = buildHTML(id);
          //ラベルの直前のプレビュー群にプレビューを追加
          var prevContent = $('.label-content').prev();
          $(prevContent).append(html);
        }
        //イメージを追加
        $(`#preview-box__${id} img`).attr('src', `${image}`);
        var count = $('.preview-box').length;
        //プレビューが5個あったらラベルを隠す 
        if (count == 5) { 
          $('.label-content').hide();
        }

        //プレビュー削除したフィールドにdestroy用のチェックボックスがあった場合、チェックを外す=============
        if ($(`#item_images_attributes_${id}__destroy`)){
          $(`#item_images_attributes_${id}__destroy`).prop('checked',false);
        } 
        //=============================================================================
        
        //ラベルのwidth操作
        setLabel();
        //ラベルのidとforの値を変更
        if(count < 5){
          $('.label-box').attr({id: `label-box--${count}`,for: `item_images_attributes_${count}_image`});
        }
      }
    });

    // 画像の削除
    $(document).on('click', '.delete-box', function() {
      var count = $('.preview-box').length;
      setLabel(count);
      var id = $(this).attr('id').replace(/[^0-9]/g, '');
      $(`#preview-box__${id}`).remove();

      //新規登録時と編集時の場合分け==========================================================
      
      //新規投稿時
      //削除用チェックボックスの有無で判定
      if ($(`#item_images_attributes_${id}__destroy`).length == 0) {
        //フォームの中身を削除 
        $(`#item_images_attributes_${id}_image`).val("");
        var count = $('.preview-box').length;
        //5個めが消されたらラベルを表示
        if (count == 4) {
          $('.label-content').show();
        }
        setLabel(count);
        if(id < 5){
          $('.label-box').attr({id: `label-box--${id}`,for: `item_images_attributes_${id}_image`});
        
        }
      } else {

        //投稿編集時
        $(`#item_images_attributes_${id}__destroy`).prop('checked',true);
        //5個めが消されたらラベルを表示
        if (count == 4) {
          $('.label-content').show();
        }

        //ラベルのwidth操作
        setLabel();
        //ラベルのidとforの値を変更
        //削除したプレビューのidによって、ラベルのidを変更する
        if(id < 5){
          $('.label-box').attr({id: `label-box--${id}`,for: `item_images_attributes_${id}_image`});
        }
      }
      //=============================================================================
    });
  });
});
items_controller.rb
class ItemsController < ApplicationController
  
  #省略

  def edit
    @item = Item.find(params[:id])
  end

  def update
    @item = Item.find(params[:id])
    @item.update(item_update_params)
  end

  private
  def item_params
    params.require(:item).permit(
      :name,
      [images_attributes: [:image]])
  end

  def item_update_params
    params.require(:item).permit(
      :name,
      [images_attributes: [:image, :_destroy, :id]])
  end
end

JSに関しては、コメントアウトの「============================」で囲んであるところが追記箇所です。
わかりにくかったらすみません。

動作はこのようになります。
Image from Gyazo
削除を押すとチェックボックスにチェックが入ります。
このまま『編集する』ボタンを押すと、画像が削除されます。
画像を選択した場合、チェックボックスからチェックが外れます。
この状態で『編集する』ボタンを押すと、画像が差替わります。
Image from Gyazo
編集後のリンク先を指定していないので微妙な感じですが、
each文で引っ張り出される画像が更新されているのがわかりますね。ね??

一旦これで完成です。

はぁ、疲れた。

##最後に
とりあえず形はなんとかできました。
まだエラーは残っていると思うので、完璧なものは保証できません。
自分で試した中ではエラー起こらなかったですが、
もし見落としがあったら教えていただけると嬉しいです。

僕ができるのはここまでです。参考にして頂けましたら幸いです。

あとドラッグ&ドロップやら10枚投稿への対応やら2段目のドロップボックス出現やら削除やら色々あると思いますが、
皆さんの手でよしなに実装してください。

また気が向いたら何か書きます。

おわり。

83
128
0

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
83
128

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?