某プログラミングスクールのチーム開発カリキュラムでフリーマーケットアプリを開発しました。
担当だった商品出品機能で、1つの投稿に対して商品の画像を複数枚紐づける機能を実装したので、その方法を備忘録にしました。
完成したらこんな感じ。
まずは、全体のHTML(haml)はこんな感じ。
fields_forの中でimage.indexとすることで、ビルドされた際に振られるindexを取得できます。これをカスタムデータとして持たせることで、jsの処理で要素を特定していきます、
#image-box
#previews
- if @item.persisted?
- @item.item_images.each_with_index do |image, i|
= image_tag "#{image.image}", data: { index: i}, width: "100", height: '100'
= f.fields_for :item_images do |image|
.js-file_group{"data-index" => "#{image.index}"}
= image.file_field :image, class: 'js-file'
%span.js-remove 削除
- if @item.persisted?
= image.check_box :_destroy, data:{ index: image.index }, class: 'hidden-destroy'
- if @item.persisted?
.js-file_group{"data-index" => "#{@item.item_images.count}"}
= file_field_tag :image, name: "item[item_images_attributes][#{@item.item_images.count}][image]", class: 'js-file'
.js-remove 削除
次にJSファイルはこんな感じ。
$(function(){
let fileIndex = 1
const buildFileField = (num)=> {
const html = `<div class="js-file_group" data-index="${num}">
<input class="js-file" type="file"
name="item[item_images_attributes][${num}][image]"
id="item_item_images_attributes_${num}_image">
<span class="js-remove">削除</span>
</div>`;
fileIndex += 1
return html;
}
const buildImg = (index, url)=> {
const html = `<img data-index="${index}" src="${url}" width="100px" height="100px">`;
return html;
}
$('.hidden-destroy').hide();
$('#image-box').on('change', '.js-file', function(e) {
const targetIndex = $(this).parent().data('index');
const file = e.target.files[0];
if(!file){
$(`.js-file_group[data-index=${targetIndex}]`).find(".js-remove").trigger("click");
return false;
}
var blobUrl = window.URL.createObjectURL(file);
if (img = $(`img[data-index="${targetIndex}"]`)[0]) {
img.setAttribute('src', blobUrl);
} else {
$('#previews').append(buildImg(targetIndex, blobUrl));
let limitFileField = $(".js-file_group:last").data("index");
if($(".js-file_group").length >= 10 ){
return false;
} else {
$('#image-box').append(buildFileField(fileIndex));
}
}
});
$('#image-box').on('click', '.js-remove', function() {
let limitFileField = $(".js-file_group:last").data("index");
const targetIndex = $(this).parent().data('index')
const hiddenCheck = $(`input[data-index="${targetIndex}"].hidden-destroy`);
if (hiddenCheck) hiddenCheck.prop('checked', true);
$(this).parent().remove();
$(`img[data-index="${targetIndex}"]`).remove();
if ((targetIndex == limitFileField ) || ($(".js-file_group").length >= 9)) ($('#image-box').append(buildFileField(fileIndex)));
});
});
なぜインデックスを使うのか
変数fileIndexはインデックス番号です。この番号を使って、プレビュー表示の際に紐づけます。
fileIndex += 1で新しい入力フォームが生成されるたびに、インデックス番号を1足していきます。
下の画像は検証でインデックス番号を確認しているところ
<div class="js-file_group" data-index="0">
の部分です。
const buildImg = (index, url)=> {
const html = `<img data-index="${index}" src="${url}" width="100px" height="100px">`;
return html;
}
プレビュー表示用の定数です。該当インデックス番号とそれに紐づくURLと画像サイズを変数buildImgに代入しています。
下の画像は検証でプレビュー表示のエレメントを確認しています。
<img data-index="0" src="blob:http://localhost:3000/596787c2-cbdd-493d-9b20-9e9c121f502a" width="100px" height="100px">
の部分です。
changeメソッドでイベントを発火させる
$('#image-box').on('change', '.js-file', function(e) {
#image-boxの子要素であるjs-fileに変更がある場合にイベントが発火するようになってます。
const targetIndex = $(this).parent().data('index');
選択した要素の親要素のdata属性のインデックス番号を取得して、定数targetIndexに代入しています。
const file = e.target.files[0];
ファイル名を取得して定数fileに代入しています。
if(!file){~
この部分は、「一度選択した画像ファイルを再度選択してキャンセルボタンを押すと、「選択されていません」と表示され、プレビュー表示に残骸のようなものが残る。それを解消するため、キャンセルした時に入力フォームに紐づくインデックス番号の削除ボタンを起動させるための記述です。
(!file)とは、定数fileに値がない時の条件分岐です。
定数fileに値があれば下記の処理に移ります。
var blobUrl = window.URL.createObjectURL(file);
ここは画像がファイルが選択され、変数fileの中身がある場合の処理です。ユーザーが普通に画像を選択すれば、ここのコードが動くようになってます。画像を選択しなかった場合にif(!file){ の条件分岐に渡され画像を登録できないようになってます。
if (img = $(`img[data-index="${targetIndex}"]`)[0]) {
img.setAttribute('src', blobUrl);
この部分は、画像変更の処理です。画像用のinputに変更があった際、該当indexを持ったimgタグが存在するかどうかで条件分岐をします。imgタグが存在すれば(既存画像の変更)そのsrcを変更後の画像のurlで置き換えます。
} else {
$('#previews').append(buildImg(targetIndex, blobUrl));
let limitFileField = $(".js-file_group:last").data("index");
ここは、imgタグが存在しなければ(新規画像の追加)buildImg関数を使ってimgタグを生成します。画像追加時にのみinputを増やすようにしています。
lengthメソッドで投稿枚数に制限をかけている
if($(".js-file_group").length >= 10 ){
return false;
} else {
$('#image-box').append(buildFileField(fileIndex));
}
}
この部分によって最大10枚以上の画像は投稿できないようにしています。
length番号10までは、画像入力フォームを生成するようにしてます。
※インデックス番号を利用していないことに注意
let limitFileField = $(".js-file_group:last").data("index");
最新のインデックス番号を取得して変数limitFileFieldに代入してます。
const hiddenCheck = $(`input[data-index="${targetIndex}"].hidden-destroy`);
if (hiddenCheck) hiddenCheck.prop('checked', true);
$(this).parent().remove();
$(`img[data-index="${targetIndex}"]`).remove();
削除ボタンを押した時に該当のインデックス番号の入った入力フォームとプレビュー画像を消せるようになってます。
if ((targetIndex == limitFileField ) || ($(".js-file_group").length >= 9)) ($('#image-box').append(buildFileField(fileIndex)));
もし、(現在入力されてるフォームが最新のフォームで、かつ、入力フォームのlengthが9以上なら、新しく入力フォームを生成する。という条件分岐によって、入力フォーム自体が消滅することを防ぎつつ、「現在入力されてるフォームが最新のフォーム」以外の条件でフォーム自体を消してしまう不具合を解消しています。(要するに、最新の入力フォーム以外を削除すると入力フォーム自体が消滅してしまう)
まとめ
インデックス番号=入力フォームの番号=プレビュー画像の番号と覚えてください。
length番号は入力フォームの数量を示しているだけです。
説明下手ですみません。わからないところはコメントいただけると追加で説明いたします。