Ruby
JavaScript
Rails
Ajax

[Ajax+Rails]プレビューで個別削除可能な画像アップローダの作成

RailsでAjaxを使って、画像アップローダーを作成しました。想定しているのは、メルカリのようなユーザーが商品を出品し、取引を行うサイトの出品画面です。できたことは以下です。


  • ドラッグ&ドロップで画像をアップロードし、結果を送信する前にプレビューをする。

  • そのほかのフォームの結果と一緒に画像をコントローラに送信する。

  • 画像のプレビュー時に、削除ボタンで個別にアップロードする画像を変更する。


環境

Rails 5.0.7.1

ruby 2.3.1


注意点


  • Rails初学者のため、間違っている部分が多々ある可能性がありますので、参考程度にご覧ください。

  • 実際に動いているコードから編集を加えているため、動作保証ないです。すいません。。。

  • モデル側については、解説ありません。コントローラ、ビュー、javascript部分になります。モデル側は、itemに対して複数のimagesテーブルのレコードを登録できるような中間テーブルを作成しています。


1.ビュー


new.html.haml

 / form_forを利用して、itemのモデルオブジェクトにデータを送信する。

= form_for @item, html: {id:'new_item'} do |f|
/ 中略

.item__images__container
.item__images__container__preview
/ 以下のulタグの下に、Jsからli要素を挿入することでプレビューを実現します。
%ul
.item__images__container__guide
/ ドラッグ&ドロップエリアをプレビューによって変動させたい場合以下のようなclassを作成し、プレビュー画像の数によってclassを変える。
.have__image--0
%h5 ドラッグ&ドロップ<br>でファイルをアップロード
/ 中略(そのほか様々なフォームがある想定)

.item__form__sellcontent__submit__done
= f.submit class: 'btn-default item__done', disable_with: "Save", value:"出品する"


ドラッグ&ドロップで、画像アップローダを作成するため、ビュー側には、inputタグは必要ありません。(もし、inputタグを利用して実装可能ならば、ご教示いただきたいです。。。)

ビューについては、

- プレビューの画像と削除ボタンのHTMLを挿入するためのulを設置する。

- ドラッグ&ドロップのためのエリアを設定する。(今回の場合は、.item_imagescontainer_guide)

のみでOKです。


2.コントローラー


items_controller.rb

def new

@item = Item.new
end
def create
# itemが保存できたかどうかで、画像の保存を分岐させたいために、newです。
@item = Item.new(create_params)
if @item.save

image_params[:images].each do |image|
#buildのタイミングは、newアクションでも可能かもしれません。buildすることで、saveした際にアソシエーション先のテーブルにも値を反映できるようになります。
@item.images.build
item_image = @item.images.new(image: image)
item_image.save
end
#今回は、Ajaxのみの通信で実装するためHTMLへrespondする必要がないため、jsonのみです。
respond_to do |format|
format.json
end
end
end

private
def create_params
# images以外の値についてのストロングパラメータの設定
item_params = params.require(:item).permit(:name, :description,:category_id, :size, :brand_id, :condition, :select_shipping_fee, :shipping_method, :area, :shipping_date, :price)
return item_params
end
def image_params
#imageのストロングパラメータの設定.js側でimagesをrequireすれば画像のみを引き出せるように設定する。
params.require(:images).permit({:images => []})
end


コントローラーの特徴としては、itemsとimagesの二つのテーブルを更新するためにストロングパラメータを二つ設定することです。その際に、imagesのアップロードデータのparamsのkeyを:itemにしていると、itemを保存する際に、imagesの値が許可されいないのにも関わらず、値を持っているため、unpermitted parameterが出てしまいます。他にも対処があるかもしれませんが、僕は、keyを:imageに設定することでunpermitted parameterのエラーが出ないようにしています。

(通常、form_forでモデルオブジェクトを設定すると、name属性が"item[description]"となりますが、このitem部分をimageに変更してjsで送ることでparamsのkeyを変更できます。)


3.Javascript部分

かなり長くなりますが、ご了承ください。


3.1 ドラッグアンドドロップおよび画像読み込み


item_new.js

// プレビューに挿入するHTMLの作成

function buildImage(loadedImageUri){
var html =
`<li>
<img src=
${loadedImageUri}>
<div class="item__images__container__preview__box">
<div class="item__images__container__preview__box__edit" >
編集
</div>
<div>
<a class="item__images__container__preview__box__delete">削除</a>
</div>
</div>
</li>`

return html
};
// 画像を管理するための配列を定義する。
var files_array = [];
// 通常のドラッグオーバイベントを止める。
$('.item__images__container__guide').on('dragover',function(e){
e.preventDefault();
});
// ドロップ時のイベントの作成
$('.item__images__container__guide').on('drop',function(event){
event.preventDefault();
// 何故か、dataTransferがうまくいかなかったので、originalEventから読み込んでいます。
// ここで、イベントによって得たファイルを配列で取り込んでいます。
files = event.originalEvent.dataTransfer.files;
// 画像のファイルを一つづつ、先ほどの画像管理用の配列に追加する。
for (var i=0; i<files.length; i++) {
files_array.push(files[i]);
var fileReader = new FileReader();
// ファイルが読み込まれた際に、行う動作を定義する。
fileReader.onload = function( event ) {
// 画像のurlを取得します。
var loadedImageUri = event.target.result;
// 取得したURLを利用して、ビューにHTMLを挿入する。
$(buildImage(loadedImageUri,)).appendTo(".item__images__container__preview ul").trigger("create");
};
// ファイルの読み込みを行う。
fileReader.readAsDataURL(files[i]);
}
});

読み込み部分は、「Javascript ドラッグ&ドロップ」を検索すると、比較的たくさん出てきます。他のものと異なるのは、読み込み動作時に、files_arrayに画像ファイルを追加していることです。最終的に、このfile_arrayに存在している画像を送信します。配列に代入する目的は二つあります。


  • 画面遷移せずに、再度画像がドラッグ&ドロップされた際に、前回の画像ファイルを保持する。

  • プレビューで画像が削除された際に、該当画像を特定し、送信する画像を削除する

簡単に言うと、JS側に画像を削除・追加可能なDBを配列で作成して、コントローラーへの送信が行われるまで管理するために利用します。


3.2 プレビューからの画像削除


item_new.js

// div配下のaタグがクリックされた際に、イベントを発生させる。

$(document).on('click','.item__images__container__preview a', function(){
// index関数を利用して、クリックされたaタグが、div内で何番目のものか特定する。
var index = $(".item__images__container__preview a").index(this);
// クリックされたaタグの順番から、削除すべき画像を特定し、配列から削除する。
files_array.splice(index - 1, 1);
// クリックされたaタグが含まれるli要素をHTMLから削除する。
$(this).parent().parent().parent().remove();
});

先ほど、プレビューとして追加されたli要素は、個別にそれぞれの画像を特定する要素が含まれいません。(例えば、liのクラスにナンバーをカスタムクラスで通し番号をふるなどが行われていない。)そのため、何番目のli要素が削除のイベントを受けたのかをindex()で何番目のaタグがクリックされたかを特定することで特定しています。これによって、何番目に挿入した画像かを判別し、file_arrayから削除しています。


3.3 出品フォームのコントローラへの送信


item_new.js

// submitボタンが押された際のイベント

$('#new_item').on('submit', function(e){
e.preventDefault();
// そのほかのform情報を以下の記述でformDataに追加
var formData = new FormData($(this).get(0));
// ドラッグアンドドロップで、取得したファイルをformDataに入れる。
files_array.forEach(function(file){
formData.append("image[images][]" , file)
});
$.ajax({
url: '/items',
type: "POST",
data: formData,
contentType: false,
processData: false,
dataType: 'json',
})
.done(function(data){
alert('出品に成功しました!');
})
.fail(function(XMLHttpRequest, textStatus, errorThrown){
alert('出品に失敗しました!');
});
});

items_controllerへの送信は、ajaxのみで行うため、画像以外の値を先に、formDataに追加します。その後、files_arrayに入っている画像をimage[images][]と言うkeyでformDataに追加します。この際、controllerの部分で説明したようにimage[]と言う形式で送ることで、コントローラ側で受け取った際に、paramsのkeyをimageとしてデータを送ることができます。

以上、js部分になります。


まとめ・ポイント


  • fromData.append()を利用することで、jsで取得した値をajaxで送信することができる。

  • js側に配列などを作成することで、ビューで削除された画像を削除できるようにする。

  • 本当は、Ajaxではなく通常のHTMLで行いたかったのですが、どうしてもできなかったのでAjaxで実装しました。他の方法もあるかと思います。(どうしてできなかったのかは、おまけに書きます。)

  • 今回は、出品画面のみですが、更新画面は大幅にjsを変更することが必要です。(DBに保存された画像と新規登録画像を判別する必要があるため)


おまけ

なぜ、inputタグで作成できなかったのか。


理由1:inputタグのtype=fileのvalueにjs側から値を挿入できない。

ドラッグ&ドロップでの画像アップローダーは、jsでしか実装できないため、inputタグのtype=fileに取得した値を挿入しようとしたのですが、反映されない。調べてみると、下記のようにセキュリティ上値を入れることができなそうなことが分かり、断念した。

INPUT TYPE="FILE"の初期値をセットする方法

NPUT TYPE=FILEタグでの入力値保持について


理由2.ajaxとhtmlで分けてテーブルに登録できるかわからなかった

試していないのですが、ajaxで画像をそれ以外をhtmlを送信することを考えましたが、itemが保存できなかった時にimageの処理を止めることがどうしたらいいかわからなかった(逆も同じ)。

なんとなく、buildを利用すればなんとかなるような気がしたが、一緒にデータを送った方が安全だと判断した。


理由3.input multiple= trueは、過去の値を保持できない

input multiple=trueを利用すれば簡単に複数画像をinputタグで渡すことができるのですが、画面遷移せずに再度inputタグの値を更新すると前回の値はなくなってしまうため、HTMLのみでは複数回のファイルアップロードにどのように対応していいかわからなかった。

以上になります。他のやり方がありましたらご教示いただきたいです。(CarrierWaveの機能をフルに使えばできそうな気もするのですが、、、公式のgitを読み解くことができませんでした。。。)


参考リンク

ドラッグ&ドロップで複数画像をアップロードする

CarrierWave Upload Multiple Images [2018 Update]

CarrierWave 複数の画像をコード三行で一つのカラムに保存する