完成形
(1)アップロードしたい画像(1枚~4枚まで)を選択すると、その場でプレビューします。
(2)ドラッグ&ドロップで画像の位置を入れ替えます。
(並び替え後↓↓)
(3)送信ボタンを押してアップロード。送信先のファイルでも順番を保ちます。
画像はCSSで整えています。1枚~4枚、全パターンを用意します。
JavaScriptにて、画像の枚数に応じたクラス属性を自動で適用します。
- (左から)選択した画像が1枚、2枚、3枚の時
この記事では、画像のプレビューまで行います。
目次
- おおまかな仕組み
- ファイルの構成
- (1)HTMLファイルの作成
- (2)CSSファイルの作成
-
(3)画像の枚数に応じたCSSを自動で適用
FileListオブジェクトについて -
(4)プレビュー&画像の初期位置を保存
①全ての<img>を作る
②枚数分の<div>を作り、<img>を先着順に入れていく
おおまかな仕組み
まず、<input type="file">にて、アップロードする画像を選択します。1枚かもしれないし、4枚かもしれません。
そのため、プレビューは、for文を使って画像の枚数分の<div>と<img> を作ります。
(例)画像を4枚選択
<div><img></div>
<div><img></div>
<div><img></div>
<div><img></div>
-
<div>と<img>に、0から連番を振ります
-
<div>の作成は同期処理のため、順番通りに完成します
-
<img>の作成は非同期処理を使うため、バラバラの順番に完成します
本記事では、例として画像3, 画像1, 画像2, 画像0の順に完成するとします
※<div>の配置はCSSで整えています。
- <img>を完成した順に、親要素<div>へ入れます
- <div>は常に固定です
この時、画像の位置情報が分かります。
- div-0の所に画像3
- div-1の所に画像1
- div-2の所に画像2
- div-3の所に画像0
HTMLの構造は、こうなっています。
<div id="0" ><img id="3"></div>
<div id="1" ><img id="1"></div>
<div id="2" ><img id="2"></div>
<div id="3" ><img id="0"></div>
- 続いてJavaScriptです。空の配列
photoOrderを用意します。ここで、画像の位置情報を管理していきます
const photoOrder = [];
-
photoOrder(一次元配列)のインデックスは0からで、<div>の連番と一致します
- <img>に振った連番を格納します
photoOrder = [3, 1, 2, 0];
現在、図の状態です。
- つまり、配列
photoOrderは、「どの<div>にどの<img>が入っているか」 を管理します - 画像をドラッグ&ドロップしたら、互いの場所を入れ替えればよいだけです
(例)画像3と画像1を入れ替える
・photoOrder = [1, 3, 2, 0];に更新
・<img>の位置を入れ替える - POST送信する時、4つの画像ファイルと
photoOrder = [1, 3, 2, 0];を送信します - 送信先のファイルでも、画像の枚数分の<div>と<img>を作ります。HTMLの構造も全く同じです。送信された
photoOrder = [1, 3, 2, 0];に従って、画像を表示します
<div><img></div>
<div><img></div>
<div><img></div>
<div><img></div>
それでは、実装していきます。
ファイルの構成
your_dir_name
|
|__css
| |__preview.css
|
|__js
| |__preview.js
|
|__preview.html
|
|__upload.php(今後作成)
(1)HTMLファイルの作成
preview.html【完成】
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="css/preview.css"/>
</head>
<body>
<!--★画像をプレビューする部分-->
<div id="preview">
<div id="all-photos">
<!--JavaScriptで<div>と<img>を生成-->
</div>
</div>
<!--★ファイル入力部分-->
<form id="upload" method="POST" enctype="multipart/form-data" action="upload.php">
<input type="hidden" name="max_file_size" value="1000000"/>
<input id="photo" type="file" name="photo[]" multiple/>
<input type="submit" value="投稿"/>
</form>
<script src="js/preview.js"></script>
</body>
</html>
★画像をプレビューする部分
-
all-photosに全ての画像が入ります。選択した画像は1枚かもしれないし、4枚かもしれません。なので、JavaScriptで画像の枚数分の<div>と<img> を生成します(後ほどpreview.jsを作成します)
★ファイル入力部分
enctypeオプションは、フォームデータのエンコード形式を指定するもの。(省略)アップロードに際しては明示的に指定しておかないと、サーバー側で正しくデータを受け取ることができません。
name="max_file_size"の隠しフィールドでは、アップロード可能なファイルサイズの上限(バイト単位)を指定できます。悪意あるユーザーがこれを改ざんすることは簡単なので、あくまで紳士協定的な設定と考えるべきですが、一応、設定しておきましょう。設定に際しては、<input type="file">タグの前に宣言しなければなりません。
-
name="max_file_size"は、1ファイルのサイズの上限 -
name="photo[]"とmultipleは、複数のファイルをアップロード可能にする
【参考】
・『独習PHP 第4版』 山田 祥寛(著)p.398
(2)CSSファイルの作成
画像の枚数に応じたクラスlayout-1、layout-2、layout-3、layout-4を作成します。
preview.css【完成】
body{
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#preview{
max-width: 400px;
width: 100%;
}
/* 全体のアスペクト比: 3:4 */
#all-photos{
/* #preview .layout-1, .layout-2, .layout-3, .layout-4{ */
width: 100%;
padding-top: calc(3 / 4 * 100%);
position: relative;
overflow: hidden;
}
/* 画像1枚の時(.layout-1) */
.layout-1 > div{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* 画像2枚の時(.layout-2) */
.layout-2 > div:first-child{
position: absolute;
top: 0;
left: 0;
width: 50%;
padding-top: calc(3 / 4 * 100%);
}
.layout-2 > div:nth-of-type(2){
position: absolute;
top: 0;
right: 0;
width: 50%;
padding-top: calc(3 / 4 * 100%);
}
/* 画像3枚の時(.layout-3) */
.layout-3{
width: 100%;
padding-top: calc(3 / 4 * 100%);
position: relative;
overflow: hidden;
}
.layout-3 > div:first-child{
position: absolute;
bottom: 0;
left: 0;
width: 50%;
padding-top: calc(3 / 4 * 100%);
}
.layout-3 > div:nth-of-type(2){
position: absolute;
top: 0;
right: 0;
width: 50%;
padding-top: calc(3 / 4 * 50%);
}
.layout-3 > div:nth-of-type(3){
position: absolute;
bottom: 0;
right: 0;
width: 50%;
padding-top: calc(3 / 4 * 50%);
}
/* 画像4枚の時(.layout-4) */
.layout-4 > div:first-child{
position: absolute;
top: 0;
left: 0;
width: 50%;
padding-top: calc(3 / 4 * 50%);
}
.layout-4 > div:nth-of-type(2){
position: absolute;
top: 0;
right: 0;
width: 50%;
padding-top: calc(3 / 4 * 50%);
}
.layout-4 > div:nth-of-type(3){
position: absolute;
bottom: 0;
left: 0;
width: 50%;
padding-top: calc(3 / 4 * 50%);
}
.layout-4 > div:nth-of-type(4){
position: absolute;
bottom: 0;
right: 0;
width: 50%;
padding-top: calc(3 / 4 * 50%);
}
#all-photos div img{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
#all-photos div p{
position: absolute;
margin: 0;
padding: 8px;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
}
このファイルは、送信先のファイル(upload.php)とも共有します。
(3)画像の枚数に応じたCSSを自動で適用
- JavaScriptで 「選択された画像の枚数」 を取得(※)
- 「layout-枚数」というclass属性を、
preview.htmlの<div id="all-photos">に付けます
※<input type="file">で選択した全ファイルのリストが、FileListオブジェクトにあります。ここからファイルの数を取得します。
FileListオブジェクトについて
JavaScriptで<input type="file">を取得し、filesプロパティを見ます(要素.files)。
<input id="photo" type="file" name="photo[]" multiple/>
FileListオブジェクトの書き方(要素.files)は、以下のように様々。本記事ではthis.filesとします。以下のように中身を見るには、ファイルを選択しましょう。中身についてはこちらでも触れています。
'use strict';
const photo = document.getElementById('photo');//<input type="file">を取得
photo.addEventListener("change", function(event){//changeイベント発生=ファイルが選択された時
console.log(this.files);
console.log(photo.files);
console.log(event.target.files);
});
preview.jsを作成していきます。
- lengthプロパティでファイルの数 = 画像の枚数を取得
'use strict';
const photo = document.getElementById('photo');
photo.addEventListener("change", function(){
const numberOfPhoto = this.files.length;//選択された画像の枚数
const allPhotos = document.getElementById('all-photos');
allPhotos.classList.add('layout-' + numberOfPhoto);
});
選択された画像が4枚なら、プレビューの最終形態はこうなります。
<div id="preview">
<div id="all-photos" class="layout-4">
<!-- 以下、JavaScriptで生成 -->
<div><img></div>
<div><img></div>
<div><img></div>
<div><img></div>
</div>
</div>
早速、JavaScriptで<div><img>を生成し、選択した画像をプレビューしましょう。
(4)プレビュー&画像の初期位置を保存
おおまかな仕組みで説明したことを具体的なコードにしていきます。画像を4枚選択したとします。
「①全ての<img>を作ったら、②枚数分の<div>を作り、<img>を先着順に入れていく」。
①全ての<img>を作る
- <img>を作るには、src属性が必要です
<input type="file">で選択した全画像ファイルを、1つずつ読み込み、「データURL」を取得します。src属性の値はデータURLとなります。
ここで、先ほどのFileListオブジェクトです。
FileListオブジェクトは、<input type="file">で選択した全ファイルのリスト。中身は、Fileオブジェクト(1つのファイルに関する情報)の集まりです。
Fileオブジェクト(this.files[インデックス])の中身をconsole.logで見てみると「name, size, type…」と色々確認できます。これは、メタデータです。
File オブジェクトから、ファイル名、サイズ、型、最終更新日時などのメタデータにアクセスすることができます。
【参考】
・ファイル API - 概念と使い方
その他の情報は、Fileオブジェクトに対してFileReaderオブジェクトを使用し、1つ1つのファイルの内容を読み取ることで得ることができます。
FileReaderオブジェクト
<input type="file">で選択したファイルの内容を、非同期に読み取ります
FileReaderオブジェクトを使用すると、ウェブアプリケーションは、ユーザーのコンピューターに保存されているファイル(または生のデータバッファー)の内容を非同期に読み取ることができます。
FileReaderは、ユーザーがHTMLの<input type="file">要素を使って、あるいはドラッグ&ドロップで明示的に選択したファイルの内容にのみアクセスすることができます。
そして、実際にファイルの内容を読み込むのは、FileReaderオブジェクトのメソッドです。「ほしい情報」に応じた「readAs○○」のメソッドを使用します。
今回、「ほしい情報」はsrc属性の値です。データURLを取得する、readAsDataURLメソッドを使います。
ここまでの話を図にまとめると、こういう処理を書くことになります↓
-
FileListオブジェクトの中の、各Fileオブジェクトに対し、FileReader.readAsDataURL()を実行します。各画像のデータURLを取得していきます
readAsDataURLメソッド
-
readAsDataURLメソッド
base64エンコーディングされたdata: URLの文字列を得る
readAsDataURLメソッドは、指定されたBlobまたはFileの内容を読み込むために使用されます。読み込み操作が終了すると、readyStateがDONEとなり、loadendが発生します。このとき、result属性には、ファイルのデータを表す、base64エンコーディングされたdata: URLの文字列が格納されます。
readyState、loadend、result属性など新たな単語が出てきました。
-
readyState
読み取り操作の現在の状態
FileReaderのreadyStateプロパティは、FileReaderがいる読み取り操作の現在の状態を提供します。
-
DONE(
readyStateの2)
読み取り操作が完了(成功・失敗含む)
読み取り操作が完了しています。これは、 File または Blob の全体がメモリーに読み込まれたか、ファイル読み取りエラーが発生したか、 abort() が呼び出され、読み取りがキャンセルされたか、いずれかを意味します。
-
loadend
読み取り操作が完了(成功・失敗含む)した時発生
loadendイベントは、ファイル読み込みが、成功したかどうかにかかわらず完了したときに発生します。
-
result属性
読み取り操作が成功したら、メソッドに基づくファイルの内容(ここではデータURL)。未完了・失敗はnull
FileReader の result プロパティは、ファイルの内容を返します。このプロパティは、読み取り操作が完了した後でのみ有効で、データの形式は、読み取り操作を開始するために使用されたメソッドによって異なります。
読み取り操作を開始するためにどの読み取りメソッドが使用されたかに基づく、適切な文字列または ArrayBuffer です。読み取りがまだ完了していないか、失敗した場合は、値は null になります。
readAsDataURL() result はファイルのデータを表す data: URL の文字列になります。
- readAsDataURLメソッドの引用まとめ
- ファイルの内容を読み込む
- 読み取り操作完了(成功・失敗含む)
・readyStateが2(DONE)になり、loadedイベントが発生
・result属性に、データが格納(成功なら「データURL」、未完了・失敗は「null」)
「とりあえずファイルを読み込んだから、結果返すね。resultプロパティ見てね。」
「成功したらデータURLだし、失敗したらnullだよ」
という感じです。
ファイルの読み取りに失敗した時(null)の処理も書くとよいのでしょうが、今回は「ファイルの読み取りに成功した時」 = 「データURLが取得できた時」に<img>を作成したいと思います。
そこで、「ファイルの読み取りが成功した時」 に発生するloadイベント内で<img>を作ります。
loadイベントは、ファイルが正常に読み込めたときに発生します。
実装
preview.jsの作成を進めていきます。
// 前回作成********************************************************
const photo = document.getElementById('photo');
const photoOrder = [];// ★今回追加。忘れずに!
photo.addEventListener("change", function(){
const numberOfPhoto = this.files.length;
const allPhotos = document.getElementById('all-photos');
allPhotos.classList.add('layout-' + numberOfPhoto);
// ******************************************************************
// ↓今回
const selectedPhotos = [];
const promise = new Promise((resolve) => {
for(let i = 0; i < numberOfPhoto; i++){
const fileReader = new FileReader();
fileReader.readAsDataURL(this.files[i]);
fileReader.addEventListener("load", function(){
const img = document.createElement('img');
img.src = this.result;
img.id = 'photo-' + i;
selectedPhotos.push(img);
photoOrder.push(i);
if(selectedPhotos.length == numberOfPhoto){
resolve(selectedPhotos);
}
});
}
});
});// changeイベントの終わり
for文
-
FileReader.readAsDataURL()は非同期です
通常、目の前の処理を「実行→終了」まで行ってから次の処理に進みますが、
目の前の処理を 「ブラウザ内にある別の仕組み(Web API)に実行するよう依頼」のみ行い、次の処理に進みます。「実行→終了」を待ちません。
※Web APIは大きく2つに分類され、ここでは「ブラウザーAPI」を指します。
・ブラウザーAPI : ブラウザ内に組み込まれている仕組み
・サードパーティAPI : ブラウザ内に組み込まれておらず、ウェブのどこかにある(Twitter API等)
for文のコードを見やすくしてみました。
//省略
const photoOrder = [];
//省略
const numberOfPhoto = this.files.length;
// 省略
const selectedPhotos = [];
// 省略
for(let i = 0; i < numberOfPhoto; i++){
const fileReader = new FileReader();// ①FileReaderインスタンスの生成
fileReader.readAsDataURL(this.files[i]);// ②web APIに処理の実行依頼
fileReader.addEventListener("load", function(){// ③イベントハンドラーの登録
//処理
});
}
-
i = 0
①FileReaderインスタンスの生成
②web APIに処理の実行依頼(このFileオブジェクト(this.files[0])を読み込んで、データURL返して)
③loadイベントに対し、イベントハンドラーを登録 -
i = 1
①FileReaderインスタンスの生成
②web APIに処理の実行依頼(このFileオブジェクト(this.files[1])を読み込んで、データURL返して)
③loadイベントに対し、イベントハンドラーを登録 -
i = 2
①FileReaderインスタンスの生成
②web APIに処理の実行依頼(このFileオブジェクト(this.files[2])を読み込んで、データURL返して)
③loadイベントに対し、イベントハンドラーを登録 -
i = 3
①FileReaderインスタンスの生成
②web APIに処理の実行依頼(このFileオブジェクト(this.files[3])を読み込んで、データURL返して)
③loadイベントに対し、イベントハンドラーを登録(★) - 一番最初に
this.files[3]の読み取りが終了(※ここでの例であり、実際の順は不明)
→loadイベント発生&データURLが返ってくる
・loadイベント発生時にすべき処理は(★)で既に登録しているので、それが実行される
↓
fileReader.addEventListener("load", function(){
const img = document.createElement('img');
img.src = this.result;
img.id = 'photo-' + i;
selectedPhotos.push(img);
photoOrder.push(i);
if(selectedPhotos.length == numberOfPhoto){
resolve(selectedPhotos);
}
});
- <img>作成。
src属性は、取得したデータURL。
id属性は、「該当のFileオブジェクト」=「this.files[3]」の「3」。<img id="photo-3">とする。これにより、各画像が識別できる。画像の並び順を管理するのに必要
- 空配列
selectedPhotosに、作成済の<img>をpush - 空配列
photoOrderに、<img id="photo-3">の「3」をpush
for文 終了後、プレビュー時点の画像の位置情報が完成する - 配列
selectedPhotosに、「作成済の画像が枚数分揃ったら」、resolveを投げる
新たに、配列selectedPhotosが出てきました。promiseと合わせて説明します。
promiseが必要な理由
<img>のsrc属性の値を得るために、readAsDataURL()という非同期処理を使いました。web APIに「このFileオブジェクトを読み込んでデータURLを返してね」と処理の依頼だけをして、どんどん次の処理を進めていきます。データURLが返ってきて<img>が作成されるのを待ちません。非同期処理は、結果を待ちません。しかし、「非同期処理の結果を待って次の処理に進みたい」時。promiseを使います。
プレビューだけならpromiseは不要です。データURLが返ってきた順に、<img src="データURL">を作ればいいだけです。しかし、今回は
- 作成済の<img>をドラッグ&ドロップできるようにしなければならない
- ドラッグ&ドロップした時、<img>がどの<div>に入っているか、<img>の位置情報である配列
photoOrderを更新しなければならない
<img>が作成されるのを待たないとできない処理があります。
そこで、全ての<img>が作成されたことを確認してから(※)、resolveを投げ、次の処理に行きます。resolveの引数に配列selectedPhotosを渡し、次の処理に「作成済の画像」を渡しました。つまり、作成済の画像に対して処理ができるようになっています。
(※)配列selectedPhotosには、作成済の<img>がpushされます。selectedPhotosの要素数が枚数分あるかを確かめます。
②枚数分の<div>を作り、<img>を先着順に入れていく
// 省略
if(selectedPhotos.length == numberOfPhoto){
resolve(selectedPhotos);
}
});
}
});// ↓ 今回 ↑ 前回作成**********************************
promise.then((selectedPhotos) => {
for(let i = 0; i < selectedPhotos.length; i++){
const img = selectedPhotos[i];
const div = document.createElement('div');
div.id = 'div-' + i;
div.appendChild(img);
allPhotos.appendChild(div);
}
});
});// changeイベントの終わり
promiseオブジェクトに対しthenメソッドを使用します。これで次の処理を開始します。
- 配列
selectedPhotosには、作成済の<img>が、作成された順で入っています。<img>を1つずつ取り出し、親要素<div>へ入れていきます - <div>と<img>のセットを、
all-photosに追加してプレビュー完成
HTMLの構造は以下です。
<div id="preview">
<div id="all-photos" class="layout-4">
<div id="div-0"><img id="photo-3" src="データURL"></div>
<div id="div-1"><img id="photo-1" src="データURL"></div>
<div id="div-2"><img id="photo-2" src="データURL"></div>
<div id="div-3"><img id="photo-0" src="データURL"></div>
</div>
</div>
現時点で、
- 画像の枚数に合わせたレイアウトで画像をプレビューする
-
photoOrder = [3, 1, 2, 0];になっている(※)
(プレビュー時点の画像の位置情報)
※本記事はこの順番を例にしましたが、実際はプレビューするたびに順番は様々です。
【参考】
・JavaScript FileAPIについて学ぶ
・【ES6】 JavaScript初心者でもわかるPromise講座
preview.js【現時点】
'use strict';
const photo = document.getElementById('photo');
const photoOrder = [];
photo.addEventListener("change", function(){
const numberOfPhoto = this.files.length;
const allPhotos = document.getElementById('all-photos');
allPhotos.classList.add('layout-' + numberOfPhoto);
const selectedPhotos = [];
const promise = new Promise((resolve) => {
for(let i = 0; i < numberOfPhoto; i++){
const fileReader = new FileReader();
fileReader.readAsDataURL(this.files[i]);
fileReader.addEventListener("load", function(){
const img = document.createElement('img');
img.src = this.result;
img.id = 'photo-' + i;
selectedPhotos.push(img);
photoOrder.push(i);
if(selectedPhotos.length == numberOfPhoto){
resolve(selectedPhotos);
}
});
}
});
promise.then((selectedPhotos) => {
for(let i = 0; i < selectedPhotos.length; i++){
const img = selectedPhotos[i];
const div = document.createElement('div');
div.id = 'div-' + i;
div.appendChild(img);
allPhotos.appendChild(div);
}
});
});
次は、画像のドラッグ&ドロップ編です。