##やりたいこと
画像を任意の位置でトリミングして登録したい。
(下のgifではユーザー登録時に画像をトリミングして登録→詳細画面)
##使用ツール
Rails 6.1.4.1
CarrierWave 2.2.2(ファイルのアップロード機能を簡単に追加する事が出来るgem)
MiniMagick 4.11.0(バックエンドで画像をリサイズ、トリミング出来るgem)
Cropper.js(フロントエンドで画像のトリミングが出来るJavaScriptライブラリ)
##実現するためのプロセス
①フロントエンドの処理
Cropper.jsを導入することでトリミング画面の実装及びトリミング位置を取得できる。
Cropper.js側で下図のx,y,h,wの数値を取得し、viewのhiddenタグに各値を設定。
②バックエンドの処理
MiniMagickをincludeしたCarrierWaveのImageUploaderモデル内でフロントから
送られてきたx,y,h,wをもとに画像をトリミングする。
##ソースの解説
# -------------------------------- 解説1 ----------------------------------
class User < ApplicationRecord
mount_uploader :image, ImageUploader
attr_accessor :image_x
attr_accessor :image_y
attr_accessor :image_w
attr_accessor :image_h
attr_accessor :aspect_numerator
attr_accessor :aspect_denominator
# 省略
end
# ------------------------------------------------------------------------
■解説1
imageはCarrierWaveでアップロードするためImageUploaderをマウントする。
その他トリミングに使用する値はDBに登録するわけではないので仮想属性を使用する。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>登録画面</title>
<!-------------------------------- 解説1 ---------------------------------->
<link rel="stylesheet" type="text/css" media="all" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.css" />
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.js"></script>
<!------------------------------------------------------------------------>
</head>
<body>
<div class="form_wrapper">
<h1>登録画面</h1>
<%= form_with model: @user, url: "/users", method: "post" do |f| %>
<!-------------------------------- 解説2 ---------------------------------->
<div id="image-wrapper">
<%= image_tag "default.png", id: :prev_img %>
<canvas id="cropped_canvas" style="display:none"></canvas>
</div>
<%= f.file_field :image, id: :trim_img_uploder, value: "assets/default.png" %>
<!------------------------------------------------------------------------>
<div class="field">
<%= f.label :username, "氏名" %>
<%= f.text_field :username, value: @user.username %>
</div>
<div class="field">
<%= f.label :email, "メールアドレス" %>
<%= f.text_field :email, value: @user.email %>
</div>
<!-------------------------------- 解説3 ---------------------------------->
<%= f.hidden_field :image_x, id: "image_x" %>
<%= f.hidden_field :image_y, id: "image_y" %>
<%= f.hidden_field :image_w, id: "image_w" %>
<%= f.hidden_field :image_h, id: "image_h" %>
<%= f.hidden_field :aspect_numerator, id: "aspect_numerator", value: "1.0" %>
<%= f.hidden_field :aspect_denominator ,id: "aspect_denominator", value: "1.0" %>
<button type='submit'>登録</button>
<!------------------------------------------------------------------------>
<% end %>
<!-------------------------------- 解説4 ---------------------------------->
<div id="modal_area">
<div id="modal_back_area" class="modal_back_area"></div>
<div class="modal_wrapper">
<div class="modal_padding modal_title_wrapper">
<h4>範囲を選択してください</h4>
</div>
<div class="canvas_wrapper">
<td><canvas id="source_canvas" width="1" height="1"></canvas></td>
</div>
<button type="button" id="close_button">OK</button>
</div>
</div>
</div>
<!------------------------------------------------------------------------>
</body>
</html>
■解説1
今回はCDNでCropper.jsを読み込むため記載を忘れないようにする。
■解説2
image_tagでは画像選択前にデフォルトで表示する画像を指定する。
canvasのcropped_canvasではcropper.jsでトリミング後の画像を描画する。
そのためcanvasには"display:none"を指定し、画像が選択された後にjs側で表示する。
■解説3
バックエンド処理時に使用する値をhiddenで設定する。
■解説4
画像選択時に表示するモーダルウィンドウ。
canvasのsource_canvasではcropper.jsでトリミング前の画像を描画する。
class UsersController < ApplicationController
def new
@user = User.new()
end
def create
@user = User.new(user_params)
if @user.save
redirect_to user_path(@user)
else
# エラー時の処理
end
end
def show
@user = User.find_by(:id => params[:id])
end
# -------------------------------- 解説1 ----------------------------------
private
def user_params
attrs = [
:username,
:email,
:image_x,
:image_y,
:image_w,
:image_h,
:aspect_numerator,
:aspect_denominator,
:image
]
params.require(:user).permit(attrs)
end
# ------------------------------------------------------------------------
end
■解説1
viweで設定したhidden値をストロングパラメーターに設定する。
imageよりも前にトリミング時に使用する仮想属性を記載すること。
前に記載しないと仮想属性に値が入っていない状態でImageUploader側の処理が走り
正常にトリミングされない。
class ImageUploader < CarrierWave::Uploader::Base
# -------------------------------- 解説1 ----------------------------------
include CarrierWave::MiniMagick
process resize_to_fit: [500, 500]
# ------------------------------------------------------------------------
storage :file
def store_dir
"uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
end
# -------------------------------- 解説2 ----------------------------------
version :cropped do
process :crop
end
private
def crop
manipulate! do |img|
crop_x = model.image_x.to_i
crop_y = model.image_y.to_i
crop_w = model.image_w.to_i
crop_h = model.image_h.to_i
img.crop "#{crop_w}x#{crop_h}+#{crop_x}+#{crop_y}"
img
end
end
# ------------------------------------------------------------------------
end
■解説1
MiniMagickをincludeする。(MiniMagickのメソッドを使えるようにするため。)
resize_to_fitは指定した数値をもとにリサイズするメソッド。
ここでcrop_image.jsで指定しているscaled_widthで同一の大きさにリサイズしないと
トリミング位置がずれるため注意。
■解説2
cropメソッド内で登録対象の画像に対してトリミング処理を行っている。
manipulate! do |img|は対象となる画像を取り出すおまじない。
modelは今回の場合user、image_x,y,w,hはuserモデルの仮想属性として受け取っているため
ここで呼び出すことができる。
img.cropはMiniMagickで使用できるメソッドでソースの書き方でトリミングできる。
document.addEventListener("turbolinks:load", function(){
//-------------------------------- 解説1 ----------------------------------
$('#trim_img_uploder').click(function(e){
$(this).val('');
document.getElementById("prev_img").style.display = '';
document.getElementById("cropped_canvas").style.display = 'none';
});
$('#trim_img_uploder').change(function(e){
document.getElementById("prev_img").style.display = 'none';
document.getElementById("cropped_canvas").style.display = '';
$('#modal_area').fadeIn();
});
//-------------------------------- 解説2 ----------------------------------
let cropper = null;
const scaled_width = 500;
const aspect_numerator = parseFloat(document.getElementById("aspect_numerator").value)
const aspect_denominator = parseFloat(document.getElementById("aspect_denominator").value)
const crop_aspect_ratio = aspect_denominator / aspect_numerator;
//-------------------------------- 解説3 ----------------------------------
const crop_image = function (e) {
const files = e.target.files;
if (files.length == 0) {
return;
}
let file = files[0];
let image = new Image();
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function (e) {
image.src = e.target.result;
image.onload = function () {
//-------------------------------- 解説4 ----------------------------------
let scale = scaled_width / image.width;
const canvas = document.getElementById("source_canvas");
canvas.width = image.width * scale;
canvas.height = image.height * scale;
let ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height);
if (cropper != null) {
cropper.destroy();
}
//-------------------------------- 解説5 ----------------------------------
cropper = new Cropper(canvas,
{
aspectRatio: crop_aspect_ratio,
data: {width: canvas.width, height: canvas.width * crop_aspect_ratio},
crop: function (event) {
document.getElementById("image_x").value = event.detail.x;
document.getElementById("image_y").value = event.detail.y;
document.getElementById("image_w").value = event.detail.width;
document.getElementById("image_h").value = event.detail.height;
}
}
);
//-------------------------------- 解説6 ----------------------------------
$('#close_button,#modal_back_area').click(function(){
const cropped_canvas = document.getElementById("cropped_canvas");
let ctx = cropped_canvas.getContext("2d");
let cropped_image_width = image.height * crop_aspect_ratio;
cropped_canvas.width = cropped_image_width * scale;
cropped_canvas.height = image.height * scale;
let image_x = document.getElementById("image_x").value;
let image_y = document.getElementById("image_y").value;
let image_w = document.getElementById("image_w").value;
let image_h = document.getElementById("image_h").value;
ctx.drawImage(image, image_x/scale, image_y/scale, image_w/scale , image_h/scale ,0 ,0 , cropped_canvas.width ,cropped_canvas.height);
$('#modal_area').fadeOut();
});
}
}
}
// アップローダーに画像が設定されるとcrop_imageを設定
const uploader = document.getElementById('trim_img_uploder');
uploader.addEventListener('change', crop_image);
});
■解説1
アップローダーに画像が設定された場合の挙動を記載している。
まず.clickでアップローダークリック時valueを空にする。
この処理がないと同じ画像が二度連続で設定された際モーダルが開かない。
.changeではアップローダーに画像が設定された際、もともと表示していたデフォルト画像を
非表示に設定し、トリミング後の画像表示領域を表示に設定する。その後モーダルを表示する。
■解説2
scaled_widthはモーダル内に表示する画像の大きさを定義している。
そのためここがImageUploader側でリサイズする大きさと異なるとトリミング位置が
ずれてしまうため注意。
crop_aspect_ratioは画像の縦横比を指定している。
縦横比が固定の場合はjsにべた書きしても良い。
■解説3
crop_imageはアップローダーに画像が設定されると呼ばれる関数式。
FileReaderオブジェクトはローカルのBlobやFileオブジェクトが保有するバッファの中身に、
読み取りアクセスを行う事ができる。
readAsDataURLメソッドで受け取ったfileの情報を読み取る。
onloadイベントはfileの読み込み完了後に実行したい処理を記載する。
■解説4
モーダル内に画像を描画する。
scaleで元の画像サイズと描画するサイズのスケールの%を割り出す。
canvasに描画するdrawImageメソッドでは元の画像サイズと描画サイズ両方を引数として
渡す必要がある。
canvas.getContext("2d")ではcanvasから2Dグラフィックに特化した情報を取得でき、
そこで得たctxに対してdrawImageメソッドを使用できる。
■解説5
トリミング領域を作成する。
new Cropper(トリミング対象のcanvas, トリミングのオプション)によりモーダル内の画像に対してトリミング領域を作成する。
今回はオプションは最低限のものを指定。(さらに必要な場合は公式ドキュメント参照)
cropオプションではトリミング領域を拡大縮小する度にx,y,w,hの値をhiddenタグのvalueに
設定する。
■解説6
トリミング後の画像を描画する。
モーダルのOKボタンを押したことをトリガーにトリミング後の画像を描画する。
解説4同様drawImageメソッドを使用しての描画であるため説明は割愛する。
#省略
<div class="form_wrapper">
<h1>ユーザー詳細</h1>
<div id="image-wrapper">
<%= image_tag "#{@user.image.cropped}", id: :prev_img %>
</div>
<p><%= @user.username %></p>
</div>
蛇足かもしれないが念のため。
public/uploadsフォルダ配下を確認してほしいが、トリミング前の画像とトリミング後の画像が両方保管されている。
トリミング後の画像を表示するには@user.image.croppedで呼び出すことができる。
##最後に
Ruby勉強中であるため改善点やもっと良い方法などあればご教授願いたいです。
##参考文献
以下記事太字の記事に関しましては大変参考にさせていただきました。
ありがとうございました。
■Cropper.js関連
Cropper.js で画像を切り出してCanvasに描画するサンプル
https://puarts.com/?pid=1483
CarrierwaveとMiniMagickでユーザー任意の入力値で画像をトリミングする
https://qiita.com/mochikana/items/fb7a4d4488efd5bb5ae1
公式ドキュメント
https://github.com/fengyuanchen/cropperjs/blob/main/README.md
Cropper.jsを使ってみる
https://cly7796.net/blog/javascript/try-using-cropper-js/
■FileReader関連
FileReader.onload
https://developer.mozilla.org/ja/docs/Web/API/FileReader/onload
FileReader クラスについて
https://hakuhin.jp/js/file_reader.html#FILE_READER_00
■CarrierWave関連
CarrierWave+MiniMagickで使う、画像リサイズのメソッド
https://qiita.com/wann/items/c6d4c3f17b97bb33936f
【Rails】CarrierWave・MiniMagickで画像をトリミングできない問題
https://qiita.com/1060ki/items/d48fe26a784380630f54
■その他
canvasのgetContext("2d")って何
https://qiita.com/manten120/items/86c087b937708697acec
input file に同じファイルonchangeが発火されない
https://qiita.com/Anders/items/8cdb7fc392556b275d85