k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

画像アップロード(4)IEでもドラッグ&ドロップ対応、Ajax化しつつJavaScript無効の環境にも対応

前回の記事 画像アップロード(3)ドラッグ&ドロップ対応(IE除く) の続きです。

前回のファイル入力欄を使ったドラッグ&ドロップ対応ではInternet Explolerで期待通り動作しませんでした。

IEへの対応が必須となると、通常のフォーム送信によるファイルアップロードは断念せざるを得ないでしょう。

そういうわけで、File APIはこれまではCanvasによるプレビュー画像生成時の受け渡しにのみ利用していましたが、今回はドラッグ&ドロップからのファイル送信のために利用してみます。

また今回は、ブラウザの設定でJavaScriptをOFFにされている環境でも、通常のファイル選択フォームとして動作することを目標としました。

動作確認した環境は以下の通りです。

サーバ側コード (PHP)

サーバ側のPHPコードではこれまでと同様、自作ライブラリの Volcanus_FileUploader を使います。

今回はJavaScriptがOFFの場合はこれまで通りのフォーム送信ですが、ONの場合はAjaxで送信されるとして、いくつか処理を追加しました。

index.php

<?php
/**
 * Volcanus_CanvasResizer.js + Volcanus_FileUploader
 * Ajax化
 *
 * @copyright k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include __DIR__ . '/../../../vendor/autoload.php';

use Volcanus\FileUploader\Uploader;
use Volcanus\FileUploader\FileValidator;
use Volcanus\FileUploader\File\NativeFile;

$form = [];

$form['allowableType'] = 'jpg,png';
$form['maxWidth'] = '400';
$form['maxHeight'] = '400';
$form['maxFilesize'] = '1M';
$form['maxFilesizeAsByte'] = '1048576';
$form['acceptType'] = implode(',', array_map(function($type) {
    switch($type) {
    case 'jpg':
    case 'jpeg':
        return image_type_to_mime_type(IMAGETYPE_JPEG);
    case 'gif':
        return image_type_to_mime_type(IMAGETYPE_GIF);
    case 'png':
        return image_type_to_mime_type(IMAGETYPE_PNG);
    }
}, explode(',', $form['allowableType'])));

$form['files'] = [
    'image_1' => ['label' => '画像1'],
    'image_2' => ['label' => '画像2'],
];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $uploader = new Uploader([
        'moveDirectory' => '/path/to/temp/files',
        'moveRetry' => 1,
    ]);

    $validator = new FileValidator([
        'filenameEncoding' => 'UTF-8',
        'allowableType' => $form['allowableType'],
        'maxWidth' => $form['maxWidth'],
        'maxHeight' => $form['maxHeight'],
        'maxFilesize' => $form['maxFilesize'],
        'throwExceptionOnValidate' => false,
    ]);

    $validFiles = [];
    $invalidFiles = [];

    foreach ($form['files'] as $name => $file) {

        if (isset($_FILES[$name]['size']) && $_FILES[$name]['size'] > 0) {

            $uploadedFile = new NativeFile($_FILES[$name]);

            try {

                if ($uploader->validate($uploadedFile, $validator)) {
                    $validFiles[$name] = $uploadedFile;
                } else {
                    if ($validator->hasError('notFound')) {
                        $form['files'][$name]['errors'][] = sprintf('%sはアップロードされていません。', $file['label']);
                    }
                    if ($validator->hasError('filename')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイル名が不正です。', $file['label']);
                    }
                    if ($validator->hasError('filesize')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルサイズが%sバイトを超えています。', $file['label'], $validator->config('maxFilesize'));
                    }
                    if ($validator->hasError('extension')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのアップロード可能なファイルは%sです。', $file['label'], $validator->config('allowableType'));
                    }
                    if ($validator->hasError('imageType')) {
                        $form['files'][$name]['errors'][] = sprintf('%sのファイルタイプが拡張子と一致しません。', $file['label']);
                    }
                    if ($validator->hasError('imageWidth')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの横幅が%spxを超えています。', $file['label'], $validator->config('maxWidth'));
                    }
                    if ($validator->hasError('imageHeight')) {
                        $form['files'][$name]['errors'][] = sprintf('%sの高さが%spxを超えています。', $file['label'], $validator->config('maxHeight'));
                    }
                    $invalidFiles[$name] = $uploadedFile;
                }

            } catch (\Exception $e) {
                $invalidFiles[$name] = $uploadedFile;
                $form['files'][$name]['errors'][] = sprintf('%sのアップロードに失敗しました。', $file['label']);
            }

        }

    }

    if (empty($invalidFiles) && !empty($validFiles)) {

        foreach ($validFiles as $name => $uploadedFile) {

            $form['files'][$name]['name'] = $uploadedFile->getClientFilename();
            $form['files'][$name]['size'] = $uploadedFile->getSize();
            $form['files'][$name]['type'] = $uploadedFile->getMimeType();
            $form['files'][$name]['dataUri'] = $uploadedFile->getContentAsDataUri();

            if (false !== (list($width, $height, $type, $attr) = getimagesize($uploadedFile->getPath()))) {
                $form['files'][$name]['width'] = $width;
                $form['files'][$name]['height'] = $height;
            }

            $movedPath = $uploader->move($uploadedFile);
            $form['files'][$name]['moved'] = [];
            $form['files'][$name]['moved']['path'] = $movedPath;
            $form['files'][$name]['moved']['name'] = basename($movedPath);
        }

    }

    if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&  $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') {

        header('Content-Type: application/json; charset=UTF-8');
        header('X-Content-Type-Options: nosniff');

        if (!empty($invalidFiles)) {
            header('HTTP/1.1 400 Bad Request');
        } else {
            header('HTTP/1.1 200 OK');
        }

        echo json_encode(['form' => $form], JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS);
        exit;

    }

}
include __DIR__ . '/template.php';

jQueryAjax通信では X-Requested-With リクエストヘッダに XMLHttpRequest という値をセットしてきますので、サーバ側では $_SERVER['HTTP_X_REQUESTED_WITH'] を見てAjaxかどうかを判断、Ajaxリクエストに対してはレスポンスをJSONで返しています。

実際には json_encode() 時には json_last_error() を見てエラーであればエラー用のレスポンスを返したりしますが、今回は省略してます。(そもそもエラーや例外処理も省略してますし…あくまで画像アップロード処理の例示のためのコードということで)

送信されたファイルのバリデーション結果にエラーがあれば、ステータス 400 を返します。(クライアントの入力に起因するエラーなので、400を返す方が良いとの判断です)

レスポンスのJSONは、バリデーション結果に関わらずそのまま返しています。

<?php
$form['files'][$name]['name'] = $uploadedFile->getClientFilename();
$form['files'][$name]['size'] = $uploadedFile->getSize();
$form['files'][$name]['type'] = $uploadedFile->getMimeType();
$form['files'][$name]['width'] = $width;
$form['files'][$name]['height'] = $height;

なお、ここの連想配列のキーを以前のコードから変えたのは、表示側のJavaScriptで手抜きをするための布石です。

アップローダのテンプレート (PHP)

画面のPHPテンプレートとCSS定義はこんな感じです。

template.php

<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<title>Volcanus_CanvasResizer.js + Volcanus_FileUploaderサンプル</title>
<style type="text/css">
.error {
    color: #f00;
    font-weight: bold;
}
.uploader{
    width: 420px;
    margin: 5px;
    float: left;
}
.droparea {
    width: 410px;
    height: 410px;
    border: 1px solid #999;
    background-color: #eee;
    display: table;
}
.droparea .thumbnail {
    width: 100%;
    display: table-cell;
    vertical-align: middle;
    text-align: center;
}
.hidden {
    display: none;
}
.form-footer{
    clear: both;
}
.btn {
    text-decoration: none;
    text-align: center;
    background: #eee;
    border: 1px solid #ddd;
    border-radius: 4px;
    line-height: 1.5em;
    color: #111;
}
.btn:hover {
    background: #ddd;
    border: 1px solid #eee;
}
.btn:active {
    background: #666;
    color: #fff;
}
.btn-sm {
    width: 80px;
    padding: 4px 0;
}
.btn-lg {
    width: 160px;
    padding: 10px 0;
}
.btn-ok {
    background: linear-gradient(#3cc, #39c);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:hover {
    background: linear-gradient(#6cf, #3cf);
    border: 1px solid #eee;
    color: #fff;
}
.btn-ok:active {
    background: #3cc;
    color: #111;
}
</style>
</head>

<body>
<?php if (isset($error)) : ?>
    <p><strong><?=htmlspecialchars($error)?></strong></p>
<?php endif ?>
<form method="post" enctype="multipart/form-data" action="/canvas-resizer/4/">
<input type="hidden" id="MAX_FILESIZE" value="<?=htmlspecialchars($form['maxFilesizeAsByte'])?>" />
<input type="hidden" id="MAX_WIDTH" value="<?=htmlspecialchars($form['maxWidth'])?>" />
<input type="hidden" id="MAX_HEIGHT" value="<?=htmlspecialchars($form['maxHeight'])?>" />
<input type="hidden" id="ALLOWABLE_TYPE" value="<?=htmlspecialchars($form['allowableType'])?>" />

<?php foreach ($form['files'] as $name => $file) : ?>

    <div class="uploader">
        <h2><?=htmlspecialchars($file['label'])?></h2>
        <p>
            <input type="file" name="<?=htmlspecialchars($name)?>" accept="<?=htmlspecialchars($form['acceptType'])?>" />
            <input type="text" class="filename hidden" />
            <button type="button" name="doSelect" class="btn btn-sm hidden">選択</button>
            <button type="button" name="doReset" class="btn btn-sm hidden">リセット</button>
        </p>
        <p>
            対応フォーマット:<?=htmlspecialchars($form['allowableType'])?><br />
            <?=htmlspecialchars($form['maxWidth'])?> x <?=htmlspecialchars($form['maxHeight'])?>,
            <?=htmlspecialchars($form['maxFilesize'])?>バイトまで
        </p>
        <div class="droparea<?php if (!isset($file['dataUri'])) : ?> hidden<?php endif ?>">
            <div class="thumbnail">
<?php if (isset($file['dataUri'])) : ?>
                <img src="<?=htmlspecialchars($file['dataUri'])?>" />
<?php endif ?>
            </div>
        </div>
        <div class="fileinfo">
<?php if (isset($file['name'])) : ?>
            <h3><?=htmlspecialchars($file['name'])?></h3>
<?php endif ?>
            <ul>
<?php if (isset($file['size'])) : ?>
                <li>ファイルサイズ:<?=htmlspecialchars($file['size'])?></li>
<?php endif ?>
<?php if (isset($file['type'])) : ?>
                <li>ファイル種別:<?=htmlspecialchars($file['type'])?></li>
<?php endif ?>
<?php if (isset($file['width'])) : ?>
                <li>横幅:<?=htmlspecialchars($file['width'])?></li>
<?php endif ?>
<?php if (isset($file['height'])) : ?>
                <li>高さ:<?=htmlspecialchars($file['height'])?></li>
<?php endif ?>
<?php if (isset($file['moved']['path'])) : ?>
                <li>配置先:<?=htmlspecialchars($file['moved']['path'])?></li>
<?php endif ?>
            </ul>
        </div>
        <ul class="uploading-error">
<?php if (isset($file['errors'])) : ?>
<?php foreach ($file['errors'] as $error) : ?>
            <li class="error"><?=htmlspecialchars($error)?></li>
<?php endforeach ?>
<?php endif ?>
        </ul>
    </div>

<?php endforeach ?>

    <p class="form-footer">
        <input type="submit" value="送信する" class="btn btn-lg btn-ok" />
    </p>

</form>

<script type="text/javascript" src="/js/Volcanus/CanvasResizer.js"></script>
<script type="text/javascript" src="uploader.js"></script>

</body>
</html>

前回の input[type="file"] 巨大化&透明化をやめたほか、ファイル入力欄を初期状態で表示させる代わりに、JavaScriptのみで利用する物は初期状態で非表示としています。(class="hidden" が設定されている要素を非表示)

JavaScript無効の環境ではこういう風に見えます。

ダミーのテキスト入力欄やボタン、サムネイル(ドラッグ&ドロップ)領域が表示されません。

バリデーションはサーバ側のみになります。エラー後にファイル入力欄の選択状態を復元できないのは困りものですね…。

送信後の戻りでサムネイル(ドラッグ&ドロップ)領域が表示され、サーバが返したDataURIが画像として表示されます。

アップローダJavaScript

単純な機能なのに、JavaScriptが結構長くなってしまいました。後で部分ごとに見ていきますが、とりあえず全部。

uploader.js

$(function() {

    $('.uploader .hidden').removeClass('hidden');
    $('.uploader input[type="file"]').addClass('hidden');

    var MAX_FILESIZE = $('#MAX_FILESIZE').val();
    var MAX_WIDTH = $('#MAX_WIDTH').val();
    var MAX_HEIGHT = $('#MAX_HEIGHT').val();
    var ALLOWABLE_TYPE = $('#ALLOWABLE_TYPE').val();

    var filesToSend = {};

    var clearFile = function(uploader) {
        var fileId = $(uploader).find('input[type="file"]').attr('name');
        if (typeof filesToSend[fileId] !== 'undefined') {
            delete filesToSend[fileId];
        }
        $(uploader).find('input[type="file"]').val('');
        $(uploader).find('input.filename').val('');
        $(uploader).find('.thumbnail').html('');
        $(uploader).find('.fileinfo').html('');
    };

    var clearErrors = function(uploader) {
        $(uploader).find('.uploading-error').html('');
    };

    var appendError = function(uploader, message) {
        $(uploader).find('.uploading-error').append($('<li class="error">').text(message));
    };

    // バイト数を指定された小数点桁数の単位付き表示に変換
    var formatBytes = function(bytes, decimals) {
        var units = ['B','KB','MB','GB','TB','PB','EB','ZB','YB'];
        var number = '';
        var unit = '';
        var value = bytes;
        for (var i = 0; i < units.length; i++) {
            unit = units[i];
            number = value;
            if (value < 1024) {
                break;
            }
            value = value / 1024;
        }
        // @via http://www.jacklmoore.com/notes/rounding-in-javascript/
        return Number(Math.round(number + 'e' + decimals) + 'e-' + decimals).toString() + unit;
    };

    var createFileinfo = function(uploader, file) {
        if (typeof file.dataUri !== 'undefined') {
            $(uploader).find('.thumbnail').html($('<img>').attr('src', file.dataUri));
        }
        var ul = $('<ul>');
        if (typeof file.size !== 'undefined') {
            ul.append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)));
        }
        if (typeof file.type !== 'undefined') {
            ul.append($('<li>').text('ファイル種別:' + file.type));
        }
        if (typeof file.width !== 'undefined') {
            ul.append($('<li>').text('横幅:' + file.width));
        }
        if (typeof file.height !== 'undefined') {
            ul.append($('<li>').text('高さ:' + file.height));
        }
        if (typeof file.lastModifiedDate !== 'undefined') {
            ul.append($('<li>').text('更新日時:' + file.lastModifiedDate));
        }
        if (typeof file.moved !== 'undefined') {
            if (typeof file.moved.path !== 'undefined') {
                ul.append($('<li>').text('配置先:' + file.moved.path));
            }
        }
        $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(ul);
    };

    var preview = function(uploader, file) {
        var allowableTypes = ALLOWABLE_TYPE.split(',');
        if (allowableTypes.length > 0) {
            var found = false;
            var matches = file.name.split(/\.(?=[^.]+$)/);
            if (matches && typeof matches[1] !== 'undefined') {
                var extension = matches[1].toLowerCase();
                for (var i = 0; i < allowableTypes.length; i++) {
                    if (extension === allowableTypes[i]) {
                        found = true;
                        break;
                    }
                    if (allowableTypes[i] === 'jpg' && (extension === 'jpeg' || extension === 'jpg')) {
                        found = true;
                        break;
                    }
                }
            }
            if (!found) {
                appendError(uploader, 'ファイル拡張子が' + ALLOWABLE_TYPE + 'ではありません');
                clearFile(uploader);
                return;
            }
        }
        if (file.size > parseInt(MAX_FILESIZE, 10)) {
            appendError(uploader, 'ファイルサイズが' + formatBytes(MAX_FILESIZE, 1) + 'を超えています (' + formatBytes(file.size, 1) + ')');
            clearFile(uploader);
            return;
        }
        if (file.type.substring(0, 6) === 'image/') {
            var fileId = $(uploader).find('input[type="file"]').attr('name');
            var thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    createFileinfo(uploader, {
                        name: file.name,
                        size: file.size,
                        type: file.type,
                        lastModifiedDate: file.lastModifiedDate.toUTCString(),
                        width: image.width,
                        height: image.height,
                        dataUri: canvas.toDataURL('image/png')
                    });
                    if (image.width > parseInt(MAX_WIDTH, 10)) {
                        appendError(uploader, '画像の横幅が' + MAX_WIDTH + 'pxを超えています (' + image.width + 'px)');
                    }
                    if (image.height > parseInt(MAX_HEIGHT, 10)) {
                        appendError(uploader, '画像の高さが' + MAX_HEIGHT + 'pxを超えています (' + image.height + 'px)');
                    }
                }
            });
            $(uploader).find('input.filename').val(file.name);
            filesToSend[fileId] = file;
        }
    };

    $('.uploader input.filename').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doSelect"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader button[name="doReset"]').on('click', function() {
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        clearFile(uploader);
    });

    $('.uploader input[type="file"]').on('change', function(event) {
        if (event.target.files.length === 0) {
            return;
        }
        var uploader = $(this).parent().parent()[0];
        clearErrors(uploader);
        preview(uploader, event.target.files[0]);
    });

    $('.uploader .droparea').on('click', function() {
        var uploader = $(this).parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader .droparea').on('dragover dragenter', function(event) {
        event.stopPropagation();
        event.preventDefault();
        return false;
    });

    $('.uploader .droparea').on('drop', function(event) {
        var uploader = $(this).parent()[0];
        event.stopPropagation();
        event.preventDefault();
        clearErrors(uploader);
        preview(uploader, event.originalEvent.dataTransfer.files[0]);
    });

    $('form').on('submit', function(event) {
        event.stopPropagation();
        event.preventDefault();
        var url = $(this).attr('action');
        var formData = new FormData();
        for (var fileId in filesToSend) {
            formData.append(fileId, filesToSend[fileId]);
        }
        $.ajax({
            type: 'POST',
            timeout: 30000,
            url: url,
            dataType: 'json',
            processData: false,
            contentType: false,
            data: formData
        }).done(function(data, textStatus, jqXHR) {
            update(data);
            return false;
        }).fail(function(jqXHR, textStatus, errorThrown) {
            if (jqXHR.responseText) {
                update($.parseJSON(jqXHR.responseText));
            }
            return false;
        });
    });

    var update = function(data) {
        if (typeof data.form !== 'undefined' &&
            typeof data.form.files !== 'undefined'
        ) {
            for (var key in data.form.files) {
                if ($('input[name="' + key + '"]').length) {
                    var file = data.form.files[key];
                    var uploader = $('input[name="' + key + '"]').parent().parent()[0];
                    clearErrors(uploader);
                    if (typeof file.errors !== 'undefined') {
                        for (var i = 0; i < file.errors.length; i++) {
                            appendError(uploader, file.errors[i]);
                        }
                    } else {
                        createFileinfo(uploader, file);
                    }
                }
            }
        }
    };

});

JavaScriptが有効な場合は初期表示がこういう風に変わります。

まあ、見た目は前回の画面と一緒なんですけどね。

以下、前回から変わった部分をピックアップします。

$('.uploader .hidden').removeClass('hidden');
$('.uploader input[type="file"]').addClass('hidden');

ここは初期状態で class="hidden" を指定した、JavaScript有効時のみ利用する要素を表示しています。

ファイル入力欄はドラッグ&ドロップのみにするなら不要なんですが、ファイル選択ダイアログを起動するために、要素としては存在するけど非表示にしています。

var filesToSend = {};

var clearFile = function(uploader) {
    var fileId = $(uploader).find('input[type="file"]').attr('name');
    if (typeof filesToSend[fileId] !== 'undefined') {
        delete filesToSend[fileId];
    }
    $(uploader).find('input[type="file"]').val('');
    $(uploader).find('input.filename').val('');
    $(uploader).find('.thumbnail').html('');
    $(uploader).find('.fileinfo').html('');
};

送信対象のファイルオブジェクトは filesToSend というオブジェクトに <input type="file"> のname属性値をプロパティ名としてセットしています。(PHPer脳ですみません…)

なので、「リセット」ボタンなどから呼ばれる clearFile() にはこれをクリアする処理を追加しました。delete演算子でオブジェクトのプロパティごと削除しています。

var createFileinfo = function(uploader, file) {
    if (typeof file.dataUri !== 'undefined') {
        $(uploader).find('.thumbnail').html($('<img>').attr('src', file.dataUri));
    }
    var ul = $('<ul>');
    if (typeof file.size !== 'undefined') {
        ul.append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)));
    }
    if (typeof file.type !== 'undefined') {
        ul.append($('<li>').text('ファイル種別:' + file.type));
    }
    if (typeof file.width !== 'undefined') {
        ul.append($('<li>').text('横幅:' + file.width));
    }
    if (typeof file.height !== 'undefined') {
        ul.append($('<li>').text('高さ:' + file.height));
    }
    if (typeof file.lastModifiedDate !== 'undefined') {
        ul.append($('<li>').text('更新日時:' + file.lastModifiedDate));
    }
    if (typeof file.moved !== 'undefined') {
        if (typeof file.moved.path !== 'undefined') {
            ul.append($('<li>').text('配置先:' + file.moved.path));
        }
    }
    $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(ul);
};

ファイル送信のAjax化に伴い、ファイル情報の表示がファイル選択時とフォーム送信完後の両方で発生するようになるため、処理を関数で共通化しました。

(サーバ側の連想配列のキーを変更したのは、これを利用するためです)

また、画像の横幅と高さを同じオブジェクトから取得するようにしたり、細かいところを変更しています。

var preview = function(uploader, file) {
    var allowableTypes = ALLOWABLE_TYPE.split(',');
    if (allowableTypes.length > 0) {
        var found = false;
        var matches = file.name.split(/\.(?=[^.]+$)/);
        if (matches && typeof matches[1] !== 'undefined') {
            var extension = matches[1].toLowerCase();
            for (var i = 0; i < allowableTypes.length; i++) {
                if (extension === allowableTypes[i]) {
                    found = true;
                    break;
                }
                if (allowableTypes[i] === 'jpg' && (extension === 'jpeg' || extension === 'jpg')) {
                    found = true;
                    break;
                }
            }
        }
        if (!found) {
            appendError(uploader, 'ファイル拡張子が' + ALLOWABLE_TYPE + 'ではありません');
            clearFile(uploader);
            return;
        }
    }
    if (file.size > parseInt(MAX_FILESIZE, 10)) {
        appendError(uploader, 'ファイルサイズが' + formatBytes(MAX_FILESIZE, 1) + 'を超えています (' + formatBytes(file.size, 1) + ')');
        clearFile(uploader);
        return;
    }
    if (file.type.substring(0, 6) === 'image/') {
        var fileId = $(uploader).find('input[type="file"]').attr('name');
        var thumbnail = Volcanus_CanvasResizer.create({
            file: file
            ,maxWidth: 400
            ,maxHeight: 400
            ,onLoad: function(image, canvas) {
                createFileinfo(uploader, {
                    name: file.name,
                    size: file.size,
                    type: file.type,
                    lastModifiedDate: file.lastModifiedDate.toUTCString(),
                    width: image.width,
                    height: image.height,
                    dataUri: canvas.toDataURL('image/png')
                });
                if (image.width > parseInt(MAX_WIDTH, 10)) {
                    appendError(uploader, '画像の横幅が' + MAX_WIDTH + 'pxを超えています (' + image.width + 'px)');
                }
                if (image.height > parseInt(MAX_HEIGHT, 10)) {
                    appendError(uploader, '画像の高さが' + MAX_HEIGHT + 'pxを超えています (' + image.height + 'px)');
                }
            }
        });
        $(uploader).find('input.filename').val(file.name);
        filesToSend[fileId] = file;
    }
};

ドラッグ&ドロップやファイル選択から呼ばれるプレビューの処理です。

前述のファイル情報表示用の要素を生成する関数を呼んでいますが、処理の共通化のため新しいオブジェクトに詰め替えています。

また、送信対象のファイルオブジェクトを filesToSend というオブジェクトにセットしています。

    $('.uploader .droparea').on('click', function() {
        var uploader = $(this).parent()[0];
        $(uploader).find('input[type="file"]').click();
    });

    $('.uploader .droparea').on('dragover dragenter', function(event) {
        event.stopPropagation();
        event.preventDefault();
        return false;
    });

    $('.uploader .droparea').on('drop', function(event) {
        var uploader = $(this).parent()[0];
        event.stopPropagation();
        event.preventDefault();
        clearErrors(uploader);
        preview(uploader, event.originalEvent.dataTransfer.files[0]);
    });

ドラッグ&ドロップ領域へのハンドラ定義です。

クリックするとファイル選択ダイアログを開き、ドラッグするとデフォルトイベントを無効化し、ドロップするとエラーメッセージを消した上で前述のプレビュー処理を呼んでいます。

イベント処理のコードは Web アプリケーションからファイルを扱う | MDN を参考にしました。

event.stopPropagation()event.preventDefault()jQueryのイベントオブジェクトでも有効ですが、ドラッグ&ドロップでセットしたファイルが格納されている dataTransfer にはアクセスできないため、event.originalEvent.dataTransfer として素のイベントオブジェクトにアクセスしています。

参考

    $('form').on('submit', function(event) {
        event.stopPropagation();
        event.preventDefault();
        var url = $(this).attr('action');
        var formData = new FormData();
        for (var fileId in filesToSend) {
            formData.append(fileId, filesToSend[fileId]);
        }
        $.ajax({
            type: 'POST',
            timeout: 30000,
            url: url,
            dataType: 'json',
            processData: false,
            contentType: false,
            data: formData
        }).done(function(data, textStatus, jqXHR) {
            update(data);
            return false;
        }).fail(function(jqXHR, textStatus, errorThrown) {
            if (jqXHR.responseText) {
                update($.parseJSON(jqXHR.responseText));
            }
            return false;
        });
    });

    var update = function(data) {
        if (typeof data.form !== 'undefined' &&
            typeof data.form.files !== 'undefined'
        ) {
            for (var key in data.form.files) {
                if ($('input[name="' + key + '"]').length) {
                    var file = data.form.files[key];
                    var uploader = $('input[name="' + key + '"]').parent().parent()[0];
                    clearErrors(uploader);
                    if (typeof file.errors !== 'undefined') {
                        for (var i = 0; i < file.errors.length; i++) {
                            appendError(uploader, file.errors[i]);
                        }
                    } else {
                        createFileinfo(uploader, file);
                    }
                }
            }
        }
    };

最後にフォーム送信時のイベントハンドラ定義と、送信結果としてJSONを受け取った後の処理です。

デフォルトのイベント(つまりフォーム送信)を無効化した後、FormDataを使って送信対象のファイルオブジェクトをAjaxで送信します。

FormDataによるファイル送信については以下の記事が参考になります。

サーバ側ではファイル受付時とバリデーションエラー時ともに form.files というオブジェクトのリストをJSONで返していますので、jQuery.ajax() の結果に関わらず共通の処理を呼ぶようにしました。

また、サーバ側ではバリデーションエラーに対してステータス400を返していますので、バリデーションエラー時は通信に成功した場合でも失敗時のハンドラが呼ばれることになるわけですが、失敗時のレスポンスはdataTypeの指定に関わらず明示的にパースする必要があるため、 $.parseJSON(jqXHR.responseText) としています。

動作確認

クライアント側バリデーションのエラー時はこんな感じ。

クライアント側エラーを無視して送信すると、サーバ側バリデーションでエラーが返されます。

見た目は以前と同じですが、Ajax化しているので画面がリロードされません。ということは、JavaScriptをOFFにした時のように、ファイル入力欄の選択状態がクリアされません。やはり、こちらの方が使いやすいですね。

ドラッグ&ドロップして…

送信結果です。成功でも何もそれっぽいメッセージを表示していないので、ちょっと分かりづらいですが…。

今回は省略しましたが、他にもAjax化した場合は、送信中に再送信できないようにボタンを無効化したり、色々と面倒な処理が必要になってくると思います。

いわゆるシングルページアプリケーションとか、jQueryだけではちょっと作るのはしんどそうですね。(jQueryしか使えませんが…)

次はいよいよ確認画面の実装でしょうか。