k-holyのPHPとか諸々メモ

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

画像アップロード(3)ドラッグ&ドロップ対応(IE除く)

前回の記事 画像アップロード(2)クライアント側で簡易バリデーション、複数ファイル対応 の続きです。

今回は前回以上にトリッキーな手法を使って、ドラッグ&ドロップに対応してみます。

ただしこの方法は、InternetExplolerでは現時点で最新の11でも期待通りに動作しません。

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

サーバ側コード (PHP)

サーバ側のPHPコードでは前回と同様、自作ライブラリの Volcanus_FileUploader を使います。というか、今回は基本的にクライアント側の変更のみで対応しますので、前回と全く同じコードですが、一応掲載しておきます。

※2015-03-19追記:Volcanus_FileUploaderの更新に伴い、一部コードを書き換えました。

index.php

<?php
/**
 * Volcanus_CanvasResizer.js + Volcanus_FileUploader
 * ドラッグ&ドロップ対応 (IE非対応)
 *
 * @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);
        }

    }

}
include __DIR__ . '/template.php';

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

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

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;
}
.uploader input[type="file"] {
    width: 412px;
    height: 412px;
    opacity: 0;
    position: absolute;
    z-index: 1;
}
.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;
}
.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/3/">
<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="text" class="filename" />
            <button type="button" name="doSelect" class="btn btn-sm">選択</button>
            <button type="button" name="doReset" class="btn btn-sm">リセット</button>
        </p>
        <p>
            対応フォーマット:<?=htmlspecialchars($form['allowableType'])?><br />
            <?=htmlspecialchars($form['maxWidth'])?> x <?=htmlspecialchars($form['maxHeight'])?>,
            <?=htmlspecialchars($form['maxFilesize'])?>バイトまで
        </p>
        <p>
            <input type="file" name="<?=htmlspecialchars($name)?>" accept="<?=htmlspecialchars($form['acceptType'])?>" />
        </p>
        <div class="droparea">
            <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>

今回はサムネイル表示領域の一つ上の階層に、横幅・高さとも固定値 (410px)の領域を設置しています。

更に今回はファイル入力欄を透明にして、その領域に重ねるように配置しています。

.uploader input[type="file"] {
    width: 412px;
    height: 412px;
    opacity: 0;
    position: absolute;
    z-index: 1;
}
.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;
}

ファイル入力欄の412pxの2pxは枠線のぶんだけ長く設定したものです。opacityは透明度を指定するプロパティですが、0で完全に透明になります。

position:absolute で他の要素の配置に影響を与えないようにして親のボックスに基準位置を合わせ、z-index:1 で重ねるように配置します。

サムネイル表示は親のボックスを display:table で表形式にした上で、サムネイル表示領域を display:table-cell vertical-align:middle text-align:center として中身の画像をセンタリングします。

またドラッグ&ドロップの場合、accept属性によるファイルダイアログのフィルタリングが働かなくなってしまうため、JavaScript拡張子のチェックを行うようにします。

<input type="hidden" id="ALLOWABLE_TYPE" value="<?=htmlspecialchars($form['allowableType'])?>" /> の箇所はそのために入れています。

こうしてできたフォームがこれです。

opacityプロパティとか使ったのは初めてですが、まさしくクリックジャッキングの手法ですよね、これ…。

ちなみにopacity:0.5にするとこうなりました。

重なって配置されていることが分かります。

アップローダJavaScript

JavaScriptは前回とあまり変わりませんが、前述のファイル拡張子のチェック処理を追加しています。

uploader.js

$(function() {

    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 clearFile = function(uploader) {
        $(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 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 thumbnail = Volcanus_CanvasResizer.create({
                file: file
                ,maxWidth: 400
                ,maxHeight: 400
                ,onLoad: function(image, canvas) {
                    var lastModifiedDate = '';
                    if (typeof file.lastModifiedDate !== 'undefined') {
                        lastModifiedDate = file.lastModifiedDate.toUTCString();
                    }
                    $(uploader).find('.thumbnail').html($('<img>').attr('src', canvas.toDataURL('image/png')));
                    $(uploader).find('.fileinfo').html($('<h3>').text(file.name)).append(
                        $('<ul>')
                            .append($('<li>').text('ファイルサイズ:' + formatBytes(file.size, 1)))
                            .append($('<li>').text('ファイル種別:' + file.type))
                            .append($('<li>').text('横幅:' + image.width))
                            .append($('<li>').text('高さ:' + image.height))
                            .append($('<li>').text('更新日時:' + lastModifiedDate))
                    );
                    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);
        }
    };

    $('.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]);
    });

});

通常、ドラッグ&ドロップでファイルを扱おうとすると dragover, dragenter に加えて drop イベントを捕捉して event.dataTransfer から files プロパティを参照します。

今回の場合、実際にはファイル入力欄に直接ドロップしている形となるため、イベント処理については何も追加する必要はありませんでした。

前回と同様、クライアント側のバリデーションでエラーになった場合はこんな感じ。メッセージの表示箇所は変えてます。

拡張子のエラー。

アップロード完了後はDataURI形式に変換した画像と移動先のパスを表示します。

今回の手法については以下のコードを参考にしました。

修正箇所は少なく済んだのですが、初めに述べた通り、InternetExplolerではファイル入力欄へのドロップが動作しないため、あまり現実的ではありません。

次はInternetExplolerでも動くように、通常のドラッグ&ドロップ対応を行おうと思います。ファイル入力欄への値のセットはセキュリティ上の理由で禁じられているようなので、かなりの変更が必要になるでしょう…。