Laravel(6.x) Croppieを使って画像を切り取りプレビュー&保存する

Laravelで画像アップロード機能はさくっと作れると思いますが
もうちょっと凝った作りとして、アップロードする画像を

  • 画像の一部を切り取りたい
  • 切り取った画像をプレビューしたい
  • 切り取った画像で保存したい

といったことをやりたい場面が出てきます
例えば、プロフィールの画像は画像の自分が写っている箇所だけ切り取る
といったケースなどです

実際にやってみたいと思います
なおここではLaravelの実行環境が用意されているものとして進めていきます

この記事をすすめるとどうなる

モーダルで画像を切り取ってプレビュー&保存できるようになります

画像アップロード画面を作る

画像を切り取ったりといった複雑なことをやる前に
一旦シンプルに画像を登録するものを作ります
かいつまずに、ゼロから進めていきます

プロジェクト作成

composer create-project "laravel/laravel=6.*" hello-laravel-croppie

routes/web.php

以下を内容を追加

Route::get('/croppie', 'CroppieController@index')->name('croppieIndex');
Route::post('/croppie', 'CroppieController@store')->name('croppieStore');

Controller作成

php artisan make:Controller CroppieController
class CroppieController extends Controller
{
    /**
     * 画面表示
     */
    public function index(Request $request)
    {
        return view('croppie');
    }

    /**
     * 画像登録
     */
    public function store(Request $request)
    {
        $imageFile = $request->file('image');
        if (!$imageFile) {
            return view('croppie');
        }
        $storageImagePath = $imageFile->storeAs('public/images', 'croppie_image' . '.' . $imageFile->extension());
        $publicImagePath = str_replace('public/', 'storage/', $storageImagePath);
        return view('croppie', compact('publicImagePath'));
    }
}

storageへのlinkを設定

アップロードした画像をstorageディレクトリ配下に保存しますが
storage配下はクライアント側(ブラウザ)から直接参照できないので
public→storageの参照リンクを設定します

php artisan storage:link

resources/views/croppie.blade.php

croppie.blade.phpをviews配下に作成します
内容は以下です

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Croppie画像保存</title>
</head>
<body>
    @isset($publicImagePath)
        <img src ="{{asset($publicImagePath)}}">
    @endisset
    <form action="{{ route('croppieStore') }}" method="POST" enctype="multipart/form-data">
        @csrf
        <input type="file" name="image">
        <button type="submit" name="action" value="send">送信</button>
    </form>
</body>
</html>

画像アップロード画面を確認

ここまでの実装で一度画像を保存&表示できるか確認します

php artisan serve

サーバを起動して http://localhost:8000/croppie にアクセスします
以下のような画面が表示されるので、画像を選択して送信ボタンを押してみます

するとstorageに保存された画像が表示されます

ここまででまずは、画像アップロード&アップロードした画像の表示までが作れました
次に、画像をアップロードする前に選択した画像を切り取れるかつプレビュー
できるようにします

Croppie.js、JQuery、Boostrap

croppieは以下サイトにある、Installationというタイトルの
Download(github)から取得できますが
今回は簡易的なものなので、cdnから読み込むようにします

https://foliotek.github.io/Croppie/
(https://github.com/foliotek/croppie)

https://cdnjs.cloudflare.com/ajax/libs/croppie/{version}/croppie.min.css
https://cdnjs.cloudflare.com/ajax/libs/croppie/{version}/croppie.min.js

上記がcdnのurlですが、{version}の箇所は、githubのReleasesを確認します
記事を作成している現時点では v2.6.4がlatestバージョンのようです
なので、今回cdnから取得するhead内に入れるタグは以下のようになります

<link href="https://cdnjs.cloudflare.com/ajax/libs/croppie/2.6.4/croppie.min.css" rel="stylesheet">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/croppie/2.6.4/croppie.min.js"></script>

またCroppieをモーダル表示させた画面で切り取れるように
Bootstrapも導入します
さらにモーダルやCroppieにJQueryが必要となるため、合わせて導入します
CroppieはJQueryが1系が必要になるため、、3系、1系と両方を使います

コード

実際に実装していくものを順番にあげておきます

webpack.mix.jsを修正

mycropという名前でjs、cssを用意します

mix.js('resources/js/app.js', 'public/js')
    .js('resources/js/mycrop.js', 'public/js')
    .sass('resources/sass/mycrop.scss', 'public/css')
    .sass('resources/sass/app.scss', 'public/css');

resources/js/mycrop.js

mycrop.jsを新規作成します

var $uploadCrop,
    rawImg

function readFile(input, modalId, modalBodyId) {
    if (input.files && input.files[0]) {
        var reader = new FileReader();
        reader.onload = function (e) {
            $(modalBodyId).addClass('ready');
            $(modalId).modal('show');
            rawImg = e.target.result;
        }
        reader.readAsDataURL(input.files[0]);
    }
}

$uploadCrop = $('#upload-demo').croppie({
    viewport: {
        width: 100,
        height: 100,
        type: 'circle',
    },
    enforceBoundary: false,
    enableExif: false
});

$('#cropImagePop').on('shown.bs.modal', function () {
    $uploadCrop.croppie('bind', {
        url: rawImg
    }).then(function () { });
});

$('.image').on('change', function () {
    imageId = $(this).data('id');
    $('#cancelCropBtn').data('id', imageId);
    readFile(this, '#cropImagePop', '#upload-demo');
    $(this).val('');
});

$('#cropImageBtn').on('click', function (ev) {
    $uploadCrop.croppie('result', {
        type: 'base64',
        format: 'jpg',
        backgroundColor: '#fff',
        size: { width: 320, height: 320 }
    }).then(function (resp) {
        $('#image-output').attr('src', resp);
        $('#cropImage').val(resp);
        $('#cropImagePop').modal('hide');
    });
});

resources/sass/mycrop.scss

mycrop.scssファイルを新規作成します

.modal {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 1050;
    display: none;
    width: 100%;
    height: 100%;
    overflow: hidden;
    outline: 0;
}

.modal-dialog {
    position: relative;
    width: auto;
    pointer-events: none;
    max-width: 330px;
    margin: 1.75rem auto;
}

.modal-content {
    position: relative;
    display: -ms-flexbox;
    display: flex;
    -ms-flex-direction: column;
    flex-direction: column;
    width: 100%;
    pointer-events: auto;
    background-color: #fff;
    background-clip: padding-box;
    border: 1px solid rgba(0, 0, 0, 0.2);
    border-radius: 0.3rem;
    outline: 0;
}

.modal-body {
    position: relative;
    -ms-flex: 1 1 auto;
    flex: 1 1 auto;
    padding: 15px;
    font-size: 0.8em;
    line-height: 2;
}

.modal-header, .modal-footer {
    display: block;
    text-align: center;
    padding: 15px;
    border-top: 1px solid #dee2e6;
}

.modal-header .close {
    padding: 1rem 1rem;
    margin: -1rem -1rem -1rem auto;
}

button.close {
    padding: 0;
    background-color: transparent;
    border: 0;
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
}

.close {
    float: right;
    font-size: 1.5rem;
    font-weight: 700;
    line-height: 1;
    color: #000;
    text-shadow: 0 1px 0 #fff;
    opacity: 0.5;
}

#upload-demo {
    width: 300px;
    height: 300px;
    padding-bottom: 25px;
}

.modal-btn-cancel {
    text-decoration: none;
    font-size: 0.8rem;
    padding: 0.5em;
    color: #777;
    border-color: #777;
    border-radius: 3px;
    background-color: #FFF;
    cursor: pointer;
}

.modal-footer > * {
    margin: 0.25rem;
}

.modal-bton-crop {
    text-decoration: none;
    font-size: 0.8rem;
    padding: 5px 15px;
    color: #FFF;
    border-radius: 3px;
    background-color: #123456;
    border: 2px solid #123456;
    cursor: pointer;
}

#main {
    margin-top: 60px;
    color: #989DA5;
}

#image-area {
    position: relative;
}

#image-area #image-output {
    width: 100px;
}

resources/views/croppie.blade.phpを修正

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Croppie画像保存</title>
    <link rel="stylesheet" type="text/css" href="{{ asset('/css/mycrop.css') }}">
    <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/croppie/2.6.4/croppie.min.css">
</head>
<body>
<p>===========サーバから返ってきた画像================</p>
@isset($publicImagePath)
    <img src ="{{asset($publicImagePath)}}">
@endisset
<p>===========サーバへ送る画像================</p>
<form action="{{ route('croppieStore') }}" method="POST" enctype="multipart/form-data">
    @csrf
    <div id="input-form">
        <div id="image-area">
            <label>
                <input type="file" id="image" name="image" accept="image/*" class="image">
                <div id="image-style">
                    <p>===========サーバへ送る前のプレビュー画像================</p>
                    <img src="" alt="プロフィール画像" id="image-output">
                </div>
            </label>
        </div>
        <div id="main">
            <div class="modal fade" id="cropImagePop" tabindex="-1" role="dialog" aria-hidden="true">
                <div class="modal-dialog" role="document">
                    <div class="modal-content">
                    <div class="modal-header">
                        <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
                    </div>
                    <div class="modal-body">
                        <div id="upload-demo" class="center-block"></div>
                    </div>
                    <div class="modal-footer">
                        <button type="button" class="modal-btn-cancel" data-dismiss="modal">キャンセル</button>
                        <button type="button" id="cropImageBtn" class="modal-bton-crop">決定</button>
                    </div>
                    </div>
                </div>
            </div>
        </div>
        <input type="hidden" id="cropImage" name="cropImage" value="" />
        <button type="submit" name="action" value="send">送信</button>
    </div>
</form>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://code.jquery.com/jquery-1.12.4.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/croppie/2.6.4/croppie.min.js"></script>
<script src="{{ asset('/js/mycrop.js') }}"></script>
</body>
</html>

app/Http/Controllers/CroppieController.phpを修正

class CroppieController extends Controller
{
    /**
     * 画面表示
     */
    public function index(Request $request)
    {
        return view('croppie');
    }

    /**
     * 画像登録
     */
    public function store(Request $request)
    {
        if (!$request->cropImage) {
            return view('croppie');
        }

        $cropImageData = base64_decode(explode(",", explode(";", $request->cropImage)[1])[1]);

        $imagePath = '/images/croppie_image.jpeg';

        $storageImagePath = storage_path('app/public') . $imagePath;
        \file_put_contents($storageImagePath, $cropImageData);

        $publicImagePath = '/storage' . $imagePath;
        return view('croppie', compact('publicImagePath'));
    }
}

画面確認

ビルド実行

npm i && npm run dev

ビルドが完了したら、サーバを立ち上げて(php artisan serve)
画面確認してみます

初期画面はこんな感じです

ファイルを選択から、画像を選びます
すると隠れていたモーダルが表示され、画像の切り取りが行えます

スライダーや、画像を移動させたりして位置をきめ、「決定」ボタンを押します

今度は 「サーバへ送る前のプレビュー画像」の下に切り取った画像が
表示されました
まだサーバには送信しておらず、保存されていないので「送信」ボタンを押して
切り取った画像を送ります

最後に、サーバに保存されて返ってきた画像が表示されます
これで 画像を切り取ってプレビュー&切り取った画像を保存
まで出来ました!

実装ポイント

実装のポイントを説明しておきます

モーダルの丸いサークル

モーダルが表示されて、丸い枠で切り取るようにしています
それはmycrop.jsに実装した以下の箇所の typeで指定しています
画像のExif情報もここではfalseにしています
(必要な場合はtrueにするだけでなく、別途exif.jsも必要になります)

$uploadCrop = $('#upload-demo').croppie({
    viewport: {
        width: 100,
        height: 100,
        type: 'circle',
    },
    enforceBoundary: false,
    enableExif: false
});

切り取り処理をしている箇所

実際に切り取っている箇所は以下の部分です
base64、jpg形式やサイズの指定にくわえて
画像の範囲外を指定された場合は、背景色 #fffが適用されます

$('#cropImageBtn').on('click', function (ev) {
    $uploadCrop.croppie('result', {
        type: 'base64',
        format: 'jpg',
        backgroundColor: '#fff',
        size: { width: 320, height: 320 }
    }).then(function (resp) {
        $('#image-output').attr('src', resp);
        $('#cropImage').val(resp);
        $('#cropImagePop').modal('hide');
    });
});

プレビューと選択画像がない箇所

プレビューに表示されているのに、ファイルを選択の横は選択されていません
になります

これは、input type=”file”で選択した画像はそのままinput type=”file”に
入っているわけではなく、別に「切り取った画像」をbase64フォーマットで
hidden項目に入れています
先程のjsの以下部分です

$('#image-output').attr('src', resp); // プレビューに使っている方
$('#cropImage').val(resp);     // ファイル送信に必要なhiddenにセットしている方

hiddenから画像を保存している箇所

hidden項目に入れられた「切り取った画像」をサーバ側で画像として保存しています
それはCroppieController.phpの以下箇所になります

$cropImageData = base64_decode(explode(",", explode(";", $request->cropImage)[1])[1]);

$imagePath = '/images/croppie_image.jpeg';

$storageImagePath = storage_path('app/public') . $imagePath;
\file_put_contents($storageImagePath, $cropImageData);

input type=”file”なら、file()メソッドをつかってstoreが使えるのですが
hiddenに埋め込んでいる関係でbase64_decodeしてfile_put_contentsで保存しています
(なんかスマートじゃないし、セキュリティ対策ないので改善余地が一番あるところだと思ってます)

まとめ

かなり長くなってしまいましたが、Croppieで切り取った画像をプレビュー&保存する方法を書いてみました
iOSでとった画像で、回転させてたりするとExif情報をおくってOrientationを
判定したりといったことが必要になってきます

単純に画像を送って保存は簡単だなと感じるのと、
フロントエンドとサーバサイドで情報の受け渡しの勉強にもなります