現在の画像ファイルは重い
ユーザーに写真を投稿してもらうサービスにおいてファイルサイズは重要です。
最近はスマートフォンカメラの画質向上が目覚ましく数千万どころか数億画素のスマートフォンも珍しくありません。
ファイルサイズも巨大化し1枚の写真が数十MBになっています。
一方でほとんどのサービスにおいてそこまでの高解像度な画像は必要ありません。
大量のデータは取り扱うにも、保存するにもコストがかかります。
そのため、アップロードされた画像はまず最初にサーバーで適切なサイズに縮小し、より圧縮率を高く再圧縮を行うことが一般的です。
しかし巨大な画像ファイルをサーバーサイドで縮小し再圧縮を行うのにも過大なコストが必要です。
サービスが成長するにつれて、このようなコストは馬鹿にできなくなります。
一部のサービスにはアップロード可能なファイルは最大3MBのように最大アップロードのファイル容量を制限して過大なファイルがアップロードされないようにしているサービスもあります。
もし自分で撮影した写真が容量制限でアップロードできなかった場合、多くのユーザーは画像のアップロードを諦め、サービスの利用も諦めてしまうでしょう。
アップロードする前に圧縮する
これを解決する簡単な方法は、画像の縮小と再圧縮をユーザー側でアップロード前に行ってしまうことです。
これにより、ユーザーは容量制限に悩むことはなくなり、通信容量も小さくなることでレスポンスタイムが向上します。
サービス提供者は容量制限でユーザーを逃してしまうことも、過大なコストに悩む必要もなくなります。
今回はWebサイトを対象として、ブラウザ上で画像解像度を縮小後、再圧縮しアップロード前に適した形式へ変換する画像アップローダーを作ります。
アップローダーが備える機能
ブラウザ上で実行される
画像変換の処理はブラウザ上で行われます。通信前に画像サイズを縮小することで、通信量が削減されレスポンスも向上します。
サーバー側に特別な処理は不要です。
また正規のルートでアップロードされた画像は必ずサイズが小さなものになるので、サーバー側に厳し目の容量制限を設定して不正なアクセスを防ぎやすくなります。
データは通常のPostで送信
ブラウザで縮小された画像データは通常のPOSTと同じ形式でサーバーに送られます。
サーバーに特殊な処理は一切必要なく、これまでPOSTで画像を受け付けていたサービスならサーバーサイドを全く改修なく対応できるはずです。
複数画像のアップロード
一度に複数の画像をアップロードすることができます。
アップロードのプレビュー
アップロードされる画像をブラウザ上で確認することができます。
アップロード状況の確認
アップロード状況を画面上に表示することでユーザーのストレスを低減します。
カメラ撮影に対応
既存のファイルだけでなく、スマートフォンのカメラを使ってその場で撮影したデータをアップロードすることができます。
exifデータによる回転に対応
exifデータに記録された画像の回転情報を実際の画像に反映させます。
これによりexifデータに依存することなく画像の向きを固定できます。
位置情報などの削除
アップロード前にクライアントサイドで位置情報などのexifデータを削除するため個人情報の漏洩を抑止できます。
ドラッグアンドドロップでのアップロード
ドラッグアンドドロップを使って画像をアップロードすることもできます。
作ってみる
1.まずは基本的なアップロードフォームを作る
それでは、アップローダを作っていきましょう
まずは単純なファイルアップローダーです。
HTMLはスマートフォンでもパソコンでも違和感がないように content="width=device-width を指定します。
input タグにmultipleを指定することで複数ファイルを選択できるようになります。
このふぁいるをもとに機能を追加していきます。
<!DOCTYPE html>
<html lang="ja">
  <head>
    <link rel="stylesheet" href="upload.css" />
    <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
    <meta charset="UTF-8" />
  </head>
  <body>
    <input type="file" name="image_file" id="image_file" multiple accept="image/jpeg, image/gif, image/png" />
  </body>
</html>
cssにはtext-size-adjust: 100%;を指定することでスマートフォンでの文字サイズを適正に設定できます。
body {
  text-size-adjust: 100%;
}
2.JavaScriptを加える
それではJavaScriptを使ってこのアップロード処理を充実させていきましょう。
HTMLの</body>と</html>の間にJavaScriptを呼び出す処理を追加します。
 <script src="upload.js"></script>
アップロードされるファイルを処理するJavaScriptを追加します。
inputタグのchangeイベントをフックしてアップロードされるファイルを取得します。
複数ファイルが指定された場合はfilesに複数のファイルが入るのでforEachでファイルごとにfileUpload()を呼び出します。
const imageFileField = document.getElementById("image_file");
imageFileField.addEventListener("change", event => {
    const files = imageFileField.files;
    Array.from(files).forEach(file => {
        fileUpload(file);
    });
});
ファイルアップロードは時間がかかる処理のためPromiseを使って非同期処理とすることでユーザーの操作をロックしないようにします。
uploadImage()はファイルをアップロードし、showResult()は結果をコンソールに表示します。
function fileUpload(file) {
    Promise.resolve(file)
        .then(uploadImage)
        .then(showResult)
        .catch(showResult);
}
./postの部分はPOSTの送信先に置き換えてください。
function uploadImage(file) {
    return new Promise(resolve => {
        result = fetch("./post", { method: "POST", body: file })
        resolve(result);
    });
}
結果をコンソールに表示します。
Chromeの場合は右クリックで検証 を押すことでコンソールを表示できます。
function showResult(result) {
    return new Promise(resolve => {
        console.log(result);
    });
}
3.アップロードする画像を一覧表示
通常のフォームであればアップロード時にはどのような画像がアップロードされるのか見ることができず、ファイル名だけが表示されます。
しかし、ユーザー目線では実際に自分がアップロードしたいはずです。
そこで、アップロードを行う画像をサムネイル形式で表示します。
画像一覧を表示する対象としてHTMLのinputに続いてdiv#upload_listを用意します。
クラスも指定してCSSでデザインを装飾しやすくします。
    <div id="upload_list" class="gallery"></div>
cssで.galleryの見た目を調整し画像が100pxの正方形で揃えて並ぶようにします。
imgにobject-fit: cover;を設定することで画像を短辺が100pxぴったりに拡大縮小されるようにして表示させることができます。
.gallery>* {
    margin: 2px;
    display: inline-block;
    width: 100px;
    height: 100px;
    text-align: center;
    position: relative;
}
.gallery img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    position: relative;
}
JavaScriptでアップロードする画像を画面へ表示するために、loadImageとshowPreviewを追加します。
loadImageでアップロードされるファイルを読み込み、showPreviewは画像を画面に表示します。
function fileUpload(file) {
    Promise.resolve(file)
        .then(loadImage)
        .then(showPreview)
        .then(uploadImage)
        .then(showResult)
        .catch(showResult);
}
loadImageではFileReaderとImageを使ってファイルを読み込みます。
ファイルが読み込まれるとonloadが実行されるため、ファイルとイメージをセットで次の処理に渡します。
function loadImage(file) {
    return new Promise(resolve => {
        const reader = new FileReader();
        reader.onload = element => {
            const image = new Image();
            image.onload = () => {
                resolve({ "file": file, "image": image });
            };
            image.src = element.target.result;
        };
        reader.readAsDataURL(file);
    });
}
showPreviewでは読み込んだ画像を画面に表示します。
divで囲んだimage要素を作成しuploadListに追加します。
const uploadList = document.getElementById("upload_list");
function showPreview(loadedImage) {
    return new Promise(resolve => {
        image = loadedImage["image"];
        frame = document.createElement("div");
        frame.append(image);
        uploadList.append(frame);
        resolve(loadedImage);
    });
}
これによりアップロード中の画像がサムネイルとして表示されるようになりました。

4.アップロード中の状態を表示する
アップロード中の画像を表示できましたが、この画像はローカルの画像を使用しているためファイルを選択した瞬間にすぐに画像が表示されてしまいます。
これではファイルがアップロード途中なのか、完了したのかがわかりません。
そこでアップロード中の画像にはそうと分かるように印をつけます。
CSSでアップロードする画像へのエフェクトを追加します。
画像自体にはブラーをかけてややぼかしたうえで::afterを使うことで上に一枚紙がかかったように表現します
.gallery .working img {
    filter: blur(2px);
}
.gallery .working::after {
    content: "UPLOADING";
    font-size: 10px;
    line-height: 100px;
    text-align: center;
    display: block;
    background: rgba(255, 255, 255, 0.8);
    width: 100px;
    height: 100px;
    position: absolute;
    top: 0px;
}
JavaScriptでアップロード中の画像には.workingクラスを追加し、アップロードが完了したら.workingを外すことでアップロード中の表示状態を制御します。
function showPreview(loadedImage) {
    return new Promise(resolve => {
        image = loadedImage["image"];
        frame = document.createElement("div");
        frame.append(image);
        frame.classList.add("working");
        uploadList.append(frame);
        resolve(loadedImage);
    });
}
function uploadImage(loadedImage) {
    return new Promise(resolve => {
        file = loadedImage["file"];
        result = fetch("./post", { method: "POST", body: file })
        image = loadedImage["image"];
        image.parentElement.classList.remove("working");
        resolve(result);
    });
}
5.画像サイズを縮小する
本記事のメインである画像の縮小処理を追加します。
今回は、長辺に合わせる形で縦横比を維持したまま縮小処理を行います。
また、Jpeg画像の再圧縮も行うことでファイルサイズを効果的に縮小します。
fileUpdloadではloadImageのあとにresize処理を行います。
resizeは画像のリサイズと再圧縮を行いblobデータを返します。
function fileUpload(file) {
    Promise.resolve(file)
        .then(loadImage)
        .then(resize)
        .then(showPreview)
        .then(uploadImage)
        .then(showResult)
        .catch(showResult);
}
長辺の解像度と圧縮するJPEGのクオリティを指定します。
const MAX_SIZE = 1200;
const JPEG_QUALITY = 0.6;
リサイズ処理は少し長いのでインラインで説明します。
function resize(loadedImage) {
    return new Promise(resolve => {
        img = loadedImage["image"];
        file = loadedImage["file"];
        var resizedWidth;
        var resizedHeight;
元の画像が指定した解像度より小さい場合は縮小処理を行いません
    if (MAX_SIZE > img.width && MAX_SIZE > img.height) {
      resizedWidth = img.width;
      resizedHeight = img.height;
どちらかが指定した解像度より大きい場合は収まるように比率を維持して縮小します。
        if (MAX_SIZE > img.width && MAX_SIZE > img.height) {
            resizedWidth = img.width;
            resizedHeight = img.height;
        } else if (img.width > img.height) {
            const ratio = img.height / img.width;
            resizedWidth = MAX_SIZE;
            resizedHeight = MAX_SIZE * ratio;
        } else {
            const ratio = img.width / img.height;
            resizedWidth = MAX_SIZE * ratio;
            resizedHeight = MAX_SIZE;
        }
画像の縮小はcanvasを生成し、drawImageを使って先ほど求めたサイズで画像を描画して実現しています。
とても簡単です。
        canvas = document.createElement("canvas");
        canvas.width = resizedWidth;
        canvas.height = resizedHeight;
        ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, resizedWidth, resizedHeight);
toBlobを呼ぶことでcanvasに描画した画像を再度画像ファイルの形式で圧縮します。
jpeg画像の場合は指定のクオリティで非可逆な再圧縮を行います。
jpeg以外の場合は非可逆な再圧縮を行わないためqualityは1で固定します。
        var quality;
        filetype = file.type;
        if (filetype == "image/jpeg") {
            quality = JPEG_QUALITY;
        } else {
            quality = 1;
        }
        canvas.toBlob(
            blob => {
                resolve({ "image": img, "blob": blob });
            },
            filetype,
            quality
        );
    });
}
showPreviewでもリサイズされたblobを表示するように変更します。
function showPreview(resizedImage) {
    return new Promise(resolve => {
        blob = resizedImage["blob"];
        dataUrl = URL.createObjectURL(blob);
        image = resizedImage["image"];
        image.src = dataUrl;
        frame = document.createElement("div");
        frame.append(image);
        frame.classList.add("working");
        uploadList.append(frame);
        resolve(resizedImage);
    });
}
リサイズされた画像をアップロードするために、uploadImageでもfileに変わってblobをポストします
function uploadImage(resizedImage) {
    return new Promise(resolve => {
        file = resizedImage["blob"];
        result = fetch("./post", { method: "POST", body: file })
        image = resizedImage["image"];
        image.parentElement.classList.remove("working");
        resolve(result);
    });
}
6.アップロード画面の装飾
せっかくならアップロード画面はもっとおしゃれにしたいものです。
アップロードの画像ギャラリーと並ぶ形として直感的に画像をアップロードできるようにします。
画像のアイコンを使用したいためhtmlにlinkを追加してgoogleフォントのアイコンを使用します
    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
これで画像を用意することなくマテリアルアイコンが使用可能となります。
    <div id="upload_list" class="gallery">
        <label class="input_image_file">
            <i class="material-icons">add_photo_alternate</i>
            <input type="file" name="image_file" id="image_file" multiple accept="image/jpeg, image/gif, image/png" />
        </label>
    </div>
cssでアップロードボタンを装飾します。
アップロードされた画像がボタンとともに並ぶようにします
.gallery {
  display: flex;
  flex-wrap: wrap;
}
アップロードボタンを装飾します。
本来input type=”file”ボタンは装飾できないのですがinput[type="file"]をdisplay:noneで非表示にして代わりに装飾をlabelに対して行います。 これでinput type=”file”が非表示になり、かわりに装飾されたlabel`がinputボタンの代わりにユーザーの操作を受け付けます。
labelは画像のサムネイルに合わせて正方形として、マウスカーソルが乗ったときにほわっと色が変わる仕組みを追加しました。
.input_image_file {
  background: rgba(0, 0, 0, 0.1);
  box-shadow: 0 2px 8px;
  color: rgba(0, 0, 0, 0.7);
  transition-duration: 0.4s;
}
.input_image_file:hover {
  background: rgba(228, 100, 30, 0.1);
  color: rgba(228, 100, 30, 0.8);
  transition-duration: 0.2s;
}
.input_image_file:active {
  background: rgba(255, 60, 10, 0.1);
  color: rgba(255, 60, 10, 0.8);
  transition-duration: 0s;
}
.input_image_file input[type="file"] {
  display: none;
}
.input_image_file .material-icons {
  font-size: 40px;
  line-height: 96px;
}
7.画面回転問題
さて、この方法でアップロードしていくと、特にスマートフォンでカメラ撮影した場合に画像が回転してしまうことがあります。
これはスマートフォンの縦横画像の制御に起因しています。
スマートフォンのカメラは撮影するときに縦向きに持つと縦長の画像、横向きに持つと横長の画像で保存されます。
これは、スマートフォン内で向き情報に基づいて画像の向きを切り替えていることで実現しているのですが、保存時には画像データ自体は回転せず、単にexifのOrientationに回転情報を記録することで実現しています。
表示側でexifのOrientationをもとに画像を適切な向きに切り替えて表示します。
しかし、今回Canvasに画像を生成し保存する過程でexif情報はすべて消えてしまいます。
これにより縦横情報がなくなってしまい、画像ファイルそのままの状態で表示されてしまうのです。
exif情報をそのまま保存しても良いのですが、exif情報には個人情報などが含まれる場合も多いので画像データにexif情報を残したくない場合も多いです。
今回は画像縮小後に画像自体を回転してexif情報がない状態でも正しく画像を表示させます。
exif情報の取得はexif-jsを使わせてもらいました。簡単にJavaScript上でファイル内のexif情報を取得することができます。
htmlでscriptを追加しexif-jsを取得します。
  <script async src="https://cdn.jsdelivr.net/npm/exif-js"></script>
exif.jsで画像を取得するにはArrayBufferを取得する必要があります。
function base64ToArrayBuffer(base64) {
    base64 = base64.replace(/^data\:([^\;]+)\;base64,/gim, "");
    var binaryString = atob(base64);
    var len = binaryString.length;
    var bytes = new Uint8Array(len);
    for (var i = 0; i < len; i++) {
        bytes[i] = binaryString.charCodeAt(i);
    }
    return bytes.buffer;
}
ラジアン角度へ変換するための定数を宣言しておきます。
const TO_RADIANS = Math.PI / 180;
読み込んだ画像の.srcから画像データをbase64形式で取得することができます。取得した情報をArrayBufferに変換してEXIF.readFromBinaryFile();でExif情報を取得します。
function resize(loadedImage) {
    return new Promise(resolve => {
        img = loadedImage["image"];
        file = loadedImage["file"];
        var arrayBuffer = base64ToArrayBuffer(img.src);
        exif = EXIF.readFromBinaryFile(arrayBuffer);
exifのOrientationには1〜8までの値が入ります。
1はそのまま
2は左右が反転
3は180度回転
4は左右反転し180度回転
5は左右反転し反時計回りに90度回転
6は反時計回りに90度回転
7は左右反転し時計回りに90度回転
8は時計回りに90度回転されています。
この値を使ってそれぞれ逆の向きに回転させることで、Orientationに頼らず画像を取得することができます。
今回左右反転についてはそのまま尊重して残すことにし、回転方向のみを揃えます。
Exif情報から回転させる角度を求めてrotateに格納します。
function resize(loadedImage) {
    return new Promise(resolve => {
        img = loadedImage["image"];
        file = loadedImage["file"];
        var arrayBuffer = base64ToArrayBuffer(img.src);
        exif = EXIF.readFromBinaryFile(arrayBuffer);
        if (exif && exif.Orientation) {
            switch (exif.Orientation) {
                case 3:
                case 4:
                    rotate = 180;
                    break;
                case 6:
                case 5:
                    rotate = 90;
                    break;
                case 8:
                case 7:
                    rotate = -90;
                    break;
                default:
                    rotate = 0;
            }
        } else {
            rotate = 0;
        }
リサイズ後の画像サイズを求める場所は変更なしです。
        var resizedWidth;
        var resizedHeight;
        if (MAX_SIZE > img.width && MAX_SIZE > img.height) {
            resizedWidth = img.width;
            resizedHeight = img.height;
        } else if (img.width > img.height) {
            const ratio = img.height / img.width;
            resizedWidth = MAX_SIZE;
            resizedHeight = MAX_SIZE * ratio;
        } else {
            const ratio = img.width / img.height;
            resizedWidth = MAX_SIZE * ratio;
            resizedHeight = MAX_SIZE;
        }
        canvas = document.createElement("canvas");
90度回転が発生した場合縦横の比率が逆転するため、キャンバスのサイズの縦横を反転させます。
        canvas = document.createElement("canvas");
        if (rotate == 90 || rotate == -90) {
            canvas.height = resizedWidth;
            canvas.width = resizedHeight;
        } else {
            canvas.width = resizedWidth;
            canvas.height = resizedHeight;
        }
rotateを呼ぶことで画像を回転させます。回転角度はラジアン角度で指定するため、rotateにラジアン角度と角度の比率をかけてラジアン角度に変更しています。
        ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, resizedWidth, resizedHeight);
        ctx.rotate(rotate * TO_RADIANS);
        var quality;
        filetype = file.type;
        if (filetype == "image/jpeg") {
            quality = JPEG_QUALITY;
        } else {
            quality = 1;
        }
        canvas.toBlob(
            blob => {
                resolve({ "image": img, "blob": blob });
            },
            filetype,
            quality
        );
8.アップロード中の画像を近くに表示する
これまではファイルを続けてアップロードすると新しい画像をGalleryに追加していたため、アップロードされる画像が増えるほど新しい画像がアップロードボタンから離れて表示されるという問題が有りました。
おそらくユーザーはアップロードボタンに視点があり、最後にアップロードした画像の進捗が一番興味あるはずなので、最後にアップロードした画像を一番近くに表示するようにします
HTMLのlabelにidを明示的に設定します。
      <label id="image_file_label" class="input_image_file">
JavaScriptでラベルを取得し格納します。
const imageFileLabel = document.getElementById("image_file_label");
JavaScriptのshowPreview()にて
uploadList.append(frame); の部分を次のように変更してlabelの次に新しい画像が追加されるようにします
 uploadList.insertBefore(frame, imageFileLabel.nextSibling);
9.ドラッグアンドドロップに対応する
ボタンを押して画像をアップロードするだけでなく、ドラッグアンドドロップで画像を受け付けられるとより親切です。
これもJavaScriptで実装可能です。
cssで.galleryに枠を付けてDrop hereと表示することでドロップ可能であることを伝えます
.gallery {
    display: flex;
    flex-wrap: wrap;
    border-radius: 8px;
    border: rgba(128, 128, 128, 0.8);
    padding: 1em;
    box-shadow: inset 0 6px 12px rgba(0, 0, 0, 0.3);
}
.gallery::after {
    content: "Drop here";
    display: flex;
    flex: auto;
    align-items: center;
    justify-content: space-around;
}
さらに.dragoverにファイルがドラッグされたときの装飾を行います。
これによりこの場所にドロップ可能であるとさらに直感的に伝える事ができます。
.dragover {
  background: rgb(255, 228, 200);
}
HTML要素の上にファイルがドラッグされるとdragoverイベントが発火されるので、JavaScriptによりuploadListに.dragoverクラスを付与します。
uploadList.addEventListener("dragover", event => {
    event.preventDefault();
    event.dataTransfer.dropEffect = "copy";
    uploadList.classList.add("dragover");
});
ファイルがドロップされずに要素外へいくとdragleaveイベントが発火されるので、JavaScriptでuploadListに付与したdragoverを外します。
これでドラッグ可能な場所にファイルがドラッグされた時に背景色を変える演出が実現できました。
uploadList.addEventListener("dragleave", event => {
    event.preventDefault();
    uploadList.classList.remove("dragover");
});
ファイルがドロップされたときはJavaScriptでuploadListに付与したdragoverを外して、背景色を戻すとともに、イベントのdataTransfer.filesでドロップされたファイル情報を取得し、アップロード処理を行います。
uploadList.addEventListener("drop", event => {
    event.preventDefault();
    uploadList.classList.remove("dragover");
    Array.from(event.dataTransfer.files).forEach(file => {
        fileUpload(file);
    });
});
完成したファイル一式
HTML
<!DOCTYPE html>
<html lang="ja">
<head>
    <link rel="stylesheet" href="upload.css" />
    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
    <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
    <meta charset="UTF-8" />
</head>
<body>
    <div id="upload_list" class="gallery">
        <label id="image_file_label" class="input_image_file">
            <i class="material-icons">add_photo_alternate</i>
            <input type="file" name="image_file" id="image_file" multiple accept="image/jpeg, image/gif, image/png" />
        </label>
    </div>
</body>
<script src="upload.js"></script>
<script async src="https://cdn.jsdelivr.net/npm/exif-js"></script>
</html>
CSS
dibody {
    text-size-adjust: 100%;
}
.gallery>* {
    margin: 2px;
    display: inline-block;
    width: 100px;
    height: 100px;
    text-align: center;
    position: relative;
}
.gallery img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    position: relative;
}
.gallery .working img {
    filter: blur(2px);
}
.gallery .working::after {
    content: "UPLOADING";
    font-size: 10px;
    line-height: 100px;
    text-align: center;
    display: block;
    background: rgba(255, 255, 255, 0.8);
    width: 100px;
    height: 100px;
    position: absolute;
    top: 0px;
}
.gallery {
    display: flex;
    flex-wrap: wrap;
    border-radius: 8px;
    border: rgba(128, 128, 128, 0.8);
    padding: 1em;
    box-shadow: inset 0 6px 12px rgba(0, 0, 0, 0.3);
}
.gallery::after {
    content: "Drop here";
    display: flex;
    flex: auto;
    align-items: center;
    justify-content: space-around;
}
.input_image_file {
    background: rgba(0, 0, 0, 0.1);
    box-shadow: 0 2px 8px;
    color: rgba(0, 0, 0, 0.7);
    transition-duration: 0.4s;
}
.input_image_file:hover {
    background: rgba(228, 100, 30, 0.1);
    color: rgba(228, 100, 30, 0.8);
    transition-duration: 0.2s;
}
.input_image_file:active {
    background: rgba(255, 60, 10, 0.1);
    color: rgba(255, 60, 10, 0.8);
    transition-duration: 0s;
}
.input_image_file input[type="file"] {
    display: none;
}
.input_image_file .material-icons {
    font-size: 40px;
    line-height: 96px;
}
.dragover {
    background: rgb(255, 228, 200);
}
JavaScript
const MAX_SIZE = 1200;
const JPEG_QUALITY = 0.6;
const imageFileField = document.getElementById("image_file");
const uploadList = document.getElementById("upload_list");
imageFileField.addEventListener("change", event => {
    const files = imageFileField.files;
    Array.from(files).forEach(file => {
        fileUpload(file);
    });
});
function fileUpload(file) {
    Promise.resolve(file)
        .then(loadImage)
        .then(resize)
        .then(showPreview)
        .then(uploadImage)
        .then(showResult)
        .catch(showResult);
}
function loadImage(file) {
    return new Promise(resolve => {
        const reader = new FileReader();
        reader.onload = element => {
            const image = new Image();
            image.onload = () => {
                resolve({ "file": file, "image": image });
            };
            image.src = element.target.result;
        };
        reader.readAsDataURL(file);
    });
}
function resize(loadedImage) {
    return new Promise(resolve => {
        img = loadedImage["image"];
        file = loadedImage["file"];
        var resizedWidth;
        var resizedHeight;
        if (MAX_SIZE > img.width && MAX_SIZE > img.height) {
            resizedWidth = img.width;
            resizedHeight = img.height;
        } else if (img.width > img.height) {
            const ratio = img.height / img.width;
            resizedWidth = MAX_SIZE;
            resizedHeight = MAX_SIZE * ratio;
        } else {
            const ratio = img.width / img.height;
            resizedWidth = MAX_SIZE * ratio;
            resizedHeight = MAX_SIZE;
        }
        canvas = document.createElement("canvas");
        canvas.width = resizedWidth;
        canvas.height = resizedHeight;
        ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, resizedWidth, resizedHeight);
        var quality;
        filetype = file.type;
        if (filetype == "image/jpeg") {
            quality = JPEG_QUALITY;
        } else {
            quality = 1;
        }
        canvas.toBlob(
            blob => {
                resolve({ "image": img, "blob": blob });
            },
            filetype,
            quality
        );
    });
}
function showPreview(resizedImage) {
    return new Promise(resolve => {
        blob = resizedImage["blob"];
        dataUrl = URL.createObjectURL(blob);
        image = resizedImage["image"];
        image.src = dataUrl;
        frame = document.createElement("div");
        frame.append(image);
        frame.classList.add("working");
        uploadList.append(frame);
        resolve(resizedImage);
    });
}
function uploadImage(resizedImage) {
    return new Promise(resolve => {
        file = resizedImage["blob"];
        result = fetch("./post", { method: "POST", body: file })
        image = resizedImage["image"];
        image.parentElement.classList.remove("working");
        resolve(result);
    });
}
function showResult(result) {
    return new Promise(resolve => {
        console.log(result);
    });
}
適切なサイズでファイルをアップロードすることはネットワーク帯域を節約する点でも有用です。
もし、アップロード元の画像を無修正で受け入れている場合は導入を検討してみてください。
なお、クライアントの処理は性悪説に基づいて作る必要があります。
この処理を実装しても別ルートで巨大なファイルを送りつけられる可能性があることは留意してください。
アップロードがこのルートのみであれば適切にサイズが縮小されるはずなので、ファイルサイズが大きなファイルは一律サーバーで拒否できます。
もし、別要素からのアクセスも許可したい場合はサーバー側にもリサイズ処理を残しておく必要があることを忘れないようにお願いします。



