フォームで写真投稿やプロフのアップロードをしようとすると、トリミング問題が出てくる。
縦画像、意図した範囲での画像ができることが理想だ。
そこで、「Cropper.js」を用いることで、ユーザーがアップした画像内でトリミングする範囲を決めることができる。
JSでクロップできる機能を作ったので、メモとして残しておく。
Table of Contents
仕上がりイメージ
まずは仕上がりのイメージをみてみる。
対象は下記の縦画像だ。

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

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

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

実際のプログラム
今回は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もしエラーなどが起きたら、下記を参照してもらいたい。