【Cropper.js】アップした画像をフロントでトリミングするプログラムを作った

2026.05.17

フォームで写真投稿やプロフのアップロードをしようとすると、トリミング問題が出てくる。

縦画像、意図した範囲での画像ができることが理想だ。

そこで、「Cropper.js」を用いることで、ユーザーがアップした画像内でトリミングする範囲を決めることができる。

JSでクロップできる機能を作ったので、メモとして残しておく。

仕上がりイメージ

まずは仕上がりのイメージをみてみる。

対象は下記の縦画像だ。

画像のアップまではいつものフォームと一緒だ。

アップ後にモーダルが立ち上がる仕様となっており、好きにクロップが可能だ。

「確定」を押すと、クロップしたものがフロントに反映される。

実際のプログラム

今回はPHP開発中のアプリに組み込んだので、bladeファイルとしてコーディングしていく。

コードは乗せるが、一つ一つの解説はコメントアウトを参照してもらいたい。

Bladeファイル

@props([
    'disabled' => false,
    'type' => 'file',
    'name',
    'required' => null,
    'label' => null, //$labelのように変数としても使える
    'note' => null,
])

{{-- Cropper.js と jQuery を一度だけ読み込む(複数コンポーネントがあっても重複しない) --}}
{{-- @push ではなく直接インラインに置くことで、このscriptより前にCDNが確実に読み込まれる --}}
@once
    <link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.12/cropper.min.css" rel="stylesheet">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.12/cropper.min.js"></script>
@endonce

<div class="">
    {{-- requiredっ呼び出し(必要な場合) --}}
    @include('components.forms.parts.requireds')

    <div class="w-full max-w-784">

        {{-- ① 選択前:アップロードエリア(確定後は非表示) --}}
        <div id="{{ $name }}_uploadBox" class="ex-box p-6 text-center bg-DBr10 mb-1">
            <svg class="mb-1 inline-block" width="20" height="20" viewBox="0 0 20 20" fill="none"
                xmlns="http://www.w3.org/2000/svg">
                <path d="M1.87183 9.86242H6.49394V16.5568H13.5069V9.86242H18.129L10.0007 0L1.87183 9.86242Z"
                    fill="#3B3A30" />
                <path d="M19.0791 17.2236H0.920898V20.0413H19.0791V17.2236Z" fill="#3B3A30" />
            </svg>
            <p class="text-base mb-0.5">ファイルをドロップ</p>
            <p class="text-sm mb-5">ファイル上限は10MBとなります</p>

            <div class="w-full max-w-[240px] block mx-auto">
                <!-- カスタムファイル選択ボタン -->
                <label for="{{ $name }}"
                    class="cursor-pointer text-base inline-flex items-center justify-center w-full p-3 border border-DS bg-white rounded-3xl hover:bg-DS hover:text-white transition cursor-pointer">
                    ファイルを選択
                </label>
                <input type="file" name="{{ $name }}" id="{{ $name }}" class="upload hidden"
                    accept="image/*" @disabled($disabled) />

                <!-- 選択されたファイル名を表示 -->
                <p class="upload_filename mt-2 text-sm text-gray-600">
                    ファイルが選択されていません
                </p>
            </div>
        </div>

        {{-- ③ 確定後:サムネイル表示エリア(確定前は非表示) --}}
        <div id="{{ $name }}_thumbnailBox" class="hidden ex-box p-6 text-center bg-DBr10 mb-1">
            <img id="{{ $name }}_thumbnail" src="" alt=""
                class="w-24 h-24 object-cover rounded mx-auto mb-3">
            <!-- 変更ボタン(クリックで再度ファイル選択) -->
            <label for="{{ $name }}"
                class="cursor-pointer text-sm inline-flex items-center justify-center px-4 py-2 border border-DS bg-white rounded-3xl hover:bg-DS hover:text-white transition">
                画像を変更する
            </label>
        </div>

        {{-- 送信用 hidden(トリミング済み画像のbase64データ) --}}
        <input type="hidden" id="{{ $name }}_previewData" name="{{ $name }}_previewData">

        @include('components.forms.parts.notes')
    </div>
</div>


{{-- ② モーダル:ファイル選択後に開く(フォームの邪魔をしない) --}}
<div id="{{ $name }}_cropperModal"
    class="fixed inset-0 z-50 hidden bg-black bg-opacity-60 items-center justify-center p-4">
    <div class="bg-white rounded-lg p-6 w-full max-w-lg mx-auto">

        <p class="font-bold text-center mb-4">画像をトリミング</p>

        <!-- クロッパー本体 -->
        <div class="edit-box w-full overflow-hidden">
            <img id="{{ $name }}_target" src="" alt="" class="max-w-full">
        </div>

        <!-- 操作ボタン -->
        <div class="flex gap-3 mt-3 justify-center">
            <button type="button" id="{{ $name }}_cropperReset"
                class="px-4 py-2 text-sm border border-gray-300 rounded hover:bg-gray-100">
                元の位置に戻す
            </button>
            <button type="button" id="{{ $name }}_cropperRotate"
                class="px-4 py-2 text-sm border border-gray-300 rounded hover:bg-gray-100">
                縦横調整
            </button>
        </div>

        {{-- リアルタイムプレビュー --}}
        <div class="mt-4 text-center">
            <p class="text-sm mb-2">プレビュー</p>
            <img src="" id="{{ $name }}_preview" name="{{ $name }}_preview" alt=""
                class="w-24 h-24 mx-auto object-cover rounded border">
        </div>

        <!-- 確定 / キャンセルボタン -->
        <div class="mt-6 flex gap-3 justify-center">
            <button type="button" id="{{ $name }}_cropperConfirm"
                class="px-6 py-2 bg-DS text-white rounded hover:opacity-90 transition">
                確定
            </button>
            <button type="button" id="{{ $name }}_cropperCancel"
                class="px-6 py-2 border border-gray-300 rounded hover:bg-gray-100 transition">
                キャンセル
            </button>
        </div>

    </div>
</div>

JSファイル

    document.addEventListener('DOMContentLoaded', function() {

        // Cropperインスタンスを保持する変数(画像更新時の破棄に使用)
        // $name をサフィックスにして複数コンポーネントでも干渉しない
        var cropper_{{ $name }}; //初回は動かず、二回目から関数内の戻り値によってインスタンス(処理)が入る

        // ① ファイル選択 → モーダルを開く
        document.getElementById('{{ $name }}').addEventListener('change', function(event) {

            var file = event.target.files[0];

            if (file) {
                var reader = new FileReader();
                reader.onload = function(e) {

                    // モーダルを表示(flex も同時に付与して中央寄せを有効化)
                    var modal = document.getElementById('{{ $name }}_cropperModal');
                    modal.classList.remove('hidden');
                    modal.classList.add('flex');
                    document.getElementById('{{ $name }}_target').src = e.target.result;

                    // すでにcropper変数に前回のインスタンス(再選択前)が保持されていたら
                    if (cropper_{{ $name }}) {
                        cropper_{{ $name }}.destroy(); //インスタンス破棄
                    }

                    // 新しい Cropper インスタンスを作成
                    cropper_{{ $name }} = afterTrim_{{ $name }}();

                };
                reader.readAsDataURL(file);
            }
        });

        function afterTrim_{{ $name }}() { //プラグインと競争するのでバニラjsで記述
            let target = document.getElementById('{{ $name }}_target');
            let cropperInstance = new Cropper(target, {
                //オプションを追加
                aspectRatio: 1, //正方形トリミング
                preview: '#{{ $name }}_preview', //リアルタイムプレビュー
                crop(event) { //cropイベントを使用してリアルタイムでプレビューを更新(トリミングボタンが不要になる)
                    let canvas = cropperInstance.getCroppedCanvas();
                    let data = canvas.toDataURL();
                    var preview = document.getElementById('{{ $name }}_preview');
                    preview.src = data; //preview要素のsrcを更新
                    // プレビュー画像のデータを hidden フィールドに設定
                    document.getElementById('{{ $name }}_previewData').value = data;
                },
                wheelZoomRatio: 0.1,
                zoomOnWheel: true,
                minCanvasWidth: 300, //黒背景含めたCanvasの最小幅
                minCanvasHeight: 300,
                minCropBoxWidth: 100, //クロップボックスの最小幅
                minCropBoxHeight: 100,
            });

            //関数のインスタンスの実行処理を呼び出し元(var cropper)に返す
            return cropperInstance; //これがないと2回目の判定ができず、画像の選びなおしができない
        }

        // 各種機能ボタン発火
        document.getElementById('{{ $name }}_cropperReset').addEventListener('click', function() {
            cropper_{{ $name }}.reset();
        });
        document.getElementById('{{ $name }}_cropperRotate').addEventListener('click', function() {
            cropper_{{ $name }}.rotate(90);
        });

        // ② 確定ボタン → サムネイルをアップロードエリアに表示してモーダルを閉じる
        document.getElementById('{{ $name }}_cropperConfirm').addEventListener('click', function() {
            let canvas = cropper_{{ $name }}.getCroppedCanvas();
            let data = canvas.toDataURL();

            // サムネイルに確定画像をセット
            document.getElementById('{{ $name }}_thumbnail').src = data;
            // hidden フィールドに保存
            document.getElementById('{{ $name }}_previewData').value = data;

            // アップロードエリアを非表示にしてサムネイルエリアを表示
            document.getElementById('{{ $name }}_uploadBox').classList.add('hidden');
            document.getElementById('{{ $name }}_thumbnailBox').classList.remove('hidden');

            // モーダルを閉じる(flex も同時に外す)
            var modal = document.getElementById('{{ $name }}_cropperModal');
            modal.classList.add('hidden');
            modal.classList.remove('flex');
        });

        // キャンセルボタン → モーダルを閉じてファイル選択をリセット
        document.getElementById('{{ $name }}_cropperCancel').addEventListener('click', function() {
            var modal = document.getElementById('{{ $name }}_cropperModal');
            modal.classList.add('hidden');
            modal.classList.remove('flex');
            document.getElementById('{{ $name }}').value = ''; //ファイル選択をリセット
            cropper_{{ $name }}.destroy();
        });

    });

クロップ済みの base64 データは image_previewData(hidden input)に入っているので、コントローラー側に base64 → ファイル変換の処理を追加すれば保存できるようになる。

Laravelではシンボリックを作成しておくこと

クロップした画像は base64 として扱われるが、下記のコマンドを既に実行してシンボリックを作成していれば、問題なく表示されるはずだ。

php artisan storage:link

もしエラーなどが起きたら、下記を参照してもらいたい。

Laravelの基本コマンド一覧メモ(随時更新)

PIC UP