【Rails】トリミング機能付き画像アップロードの実装方法(cropper.js)

プログラミング初心者の勉強ブログ #90

Railsアプリに画像アップロードにトリミング機能を付ける方法です。「carrierwave」と「mini_magick」で実装したアップローダーに、「cropper.js」というjQueryライブラリを使って画像選択後トリミング画面を挟んでいきます。

 

目次

[toc]

 

実装内容

トリミング機能

Railsアプリの「ユーザー編集画面」でトリミング機能付き画像アップロードを行います。

※jQueryインストール済み且つアップロード機能も実装済みであることを前提として書きます。

※file_fieldのデザイン変更やプレビュー表示については、【画像アップロード】file_fieldデザイン変更+画像選択時にプレビュー表示する方法に書いております。

 

viewのコーディング

users/edit.html.erb(抜粋)

# 省略

// ajax処理で使う分のパラメータをhidden_fieldで持っておく
<%= hidden_field_tag "action", params[:action] %>
<%= hidden_field_tag "route", "users" %>
<%= hidden_field_tag "idParams", @user.id %>

<div class="field image">
 <i class="far fa-check-square"></i><%= label 'ユーザーアイコン' %>

 // 隠したinputタグと紐付けしたdiv
 <div id="img_field" onClick="$('#upicon').click()" >
  <% if @user.icon.url.present? %>
   <%= image_tag @user.icon.url %>
  <% else %>
   <i class="fas fa-image"></i><i class="fas fa-file-upload add"></i>
  <% end %>
 </div>

  // inputのfileタグは隠す
 <%= file_field :icon_http, class: "icon", style: "display:none;", id: "upicon"%>
</div>

// jsでajax送信するためのボタンを作成
<div class="submit_btn">変更</div>

<%= link_to '戻る', root_path, class: "back_btn" %>
<div class="delete">
<i class="far fa-trash-alt"></i><%= link_to 'アカウントを削除する', user_path(@user.id), method: :delete, data: {confirm: 'アカウントを削除します。よろしいですか?'}, class: "delete_btn" %>
</div>

// 画像選択後のトリミング画面用div
<div class="overlay" style="display:none;" >
 <div class="crop_modal"></div>
 <div class="close_btn"></div>
 <div class="select_icon_btn">決定</div>
</div>

 

viewでやることは

  • 画像アップロード用のfile_fieldの作成
  • プレビューを表示させるdivの作成
  • トリミング画面となるdivの作成

になるかと思います。今回はfile_fieldのデザインを変更しながら実装しているので、「#img_field」というdivのクリックでfile_fieldが展開し、且つプレビュー表示用のdivとして機能してます。

 

JavaScript(jQuery)のコーディング

cropper.jsのインストール

cropperjs/dist at master · fengyuanchen/cropperjs

上のGitHubより「cropper.min.js」を「assets/javascripts」下に、

「cropper.min.css」を「assets/stylesheets」下にセットします。

 

書き込むコード(JavaScript)

$(function(){
  // cropper(トリミング部)のコーディング(詳しくはGitHub参照)
  var cropper;
  var croppable = false;
  function initIconCrop(){
    cropper = new Cropper(crop_img, {
      dragMode: 'move',
      aspectRatio: 1,
      restore: false,
      guides: false,
      center: false,
      highlight: false,
      cropBoxMovable: false,
      cropBoxResizable: false,
      minCropBoxWidth: 280,
      minCropBoxHeight: 280,
      ready: function(){
        croppable = true;
      }
    });
  }

  // croppedCanvas(トリミング後の画像をプレビューとして表示するための部分)のコーディング
  var croppedCanvas;
  function iconCropping(){
    if (!croppable) {
      alert('トリミングする画像が設定されていません。');
      return false;
    }
    croppedCanvas = cropper.getCroppedCanvas({
      width: 280,
      height: 280,
    });
    var croppedImage = document.createElement('img');
    croppedImage.src = croppedCanvas.toDataURL();
    img_field.innerHTML = '';
    img_field.appendChild(croppedImage);
  };

 // blobへ変換するためのコーディング(blobという形式で画像データを保存するため)
  var blob;
  function blobing(){
    if (croppedCanvas && croppedCanvas.toBlob){
      croppedCanvas.toBlob(function(b){
        blob = b;
        sending();
      });
    }else if(croppedCanvas && croppedCanvas.msToBlob){
      blob = croppedCanvas.msToBlob();
      sending();
    }else{
      blob = null;
      sending();
    };
  };

  // formデータをまとめてajaxでコントローラーに渡すための準備
  function sending(){
    var formData = new FormData();
    const route = $('#route').val();
    const id = $('#idParams').val();
    const action = $('#action').val();

   // CSRF対策(独自のajax処理を行う場合、head内にあるcsrf-tokenを取得して送る必要がある)
    $.ajaxPrefilter(function(options, originalOptions, jqXHR){
      var token;
      if (!options.crossDomain){
        token = $('meta[name="csrf-token"]').attr('content');

        if (token){
          return jqXHR.setRequestHeader('X-CSRF-Token', token);
        };
      };
    });

    // 入力されたformデータをformDataに入れる
    usersVal(formData);

    // newとeditでルーティングを区別
    if (action == "new"){
      $.ajax({
        url: '/' + route,
        datatype: 'json',
        type: 'post',
        data: formData,
        processData: false,
        contentType: false,
      });
    }else if (action == "edit"){
      $.ajax({
        url: '/' + route + '/' + id,
        datatype: 'json',
        type: 'patch',
        data: formData,
        processData: false,
        contentType: false,
      });
    }
  };

  // 入力されたformデータ(textやradioなど)を取得する関数作成
  function usersVal(formData){
    name = $('#name').val();
    email = $('#email').val();
    twitter = $('#twitter').val();
    facebook = $('#facebook').val();
    content = $('#content').val();
    want_to_advertise = $(':radio[name="want_to_advertise"]:checked').val();
    want_to_be_advertised = $(':radio[name="want_to_be_advertised"]:checked').val();

    if (blob != null){
      formData.append('icon', blob);
    }
    formData.append('name', name);
    formData.append('email', email);
    formData.append('twitter', twitter);
    formData.append('facebook', facebook);
    formData.append('content', content);
    if (want_to_advertise != null){
      formData.append('want_to_advertise', want_to_advertise);
    }
    if (want_to_be_advertised != null){
      formData.append('want_to_be_advertised', want_to_be_advertised);
    }
    return formData
  }

  // 画像選択時
  $('#upicon').on('change', function(e){
    file = e.target.files[0];
    reader = new FileReader();

    if(file.type.indexOf('image') < 0){
      return false;
    };
    
    # トリミング画面をフェードインさせる
    reader.onload = (function(e){
      $('.overlay').fadeIn();
      $('.crop_modal').append($('<img>').attr({
        src: e.target.result,
        height: "100%",
        class: "preview",
        id: "crop_img",
        title: file.name
      }));
      initIconCrop();
    });
    reader.readAsDataURL(file);
  });

  // トリミング決定時
  $('.select_icon_btn').on('click', function(){
    iconCropping();
    $('.overlay').fadeOut();
    $('#crop_img').remove();
    $('.cropper-container').remove();
  });

  // トリミング画面を閉じる時
  $('.close_btn').on('click', function(){
    $('.overlay').fadeOut();
    $('#crop_img').remove();
    $('.cropper-container').remove();
  });

  // コントローラーへ送信
  $('.submit_btn').on('click', function(){
    blobing();
  });
});

 

JSでは

  • 実際にトリミングを行う要素(cropper.js)の生成
  • トリミング後のプレビュー用の画像の取得と表示
  • フォームデータの取得
  • 画像データのblob形式への変換処理
  • コントローラーへパラメーターを送るためのajax処理

を行います。今回はformに入力された値をすべてjsで取得しているためその分冗長になってます。

ポイントは「formData」を用いてパラメーター送信をしている点と、CSRF対策をコーディングしておく点です。CSRF対策は開発環境では必要ありませんが、本番環境はこれが無いとエラーが出ます。

CSRFはそもそもはRailsが勝手にやってくれているもので、自分で勝手にajax通信するときはトークンを所得して送信しなければならないみたいです。

 

書き込むコード(CSS)

トリミング画面用div

// オーバーレイ
.overlay {
  z-index: 9999;
  position: fixed;
  display: none;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0,0,0,0.85);
}

// トリミング部の親要素
.overlay .crop_modal {
  width: 400px;
  height: 400px;
  position: absolute;
  top: 100px;
  right: 0;
  left: 0;
  margin: 0 auto;
}

// トリミング部を一部デフォルトからデザイン変更
.overlay .crop_modal .cropper-view-box {
  outline: none;
  border: solid 2px #C84289;
  border-radius: 5px;
}

// 閉じるボタンを擬似要素で作成
.overlay .close_btn {
  width: 50px;
  height: 50px;
  position: absolute;
  top: 20px;
  right: 40px;
}
.overlay .close_btn:before {
  display: block;
  content: "";
  width: 1px;
  height: 36px;
  background-color: #fff;
  transform: rotate(45deg);
  position: absolute;
  top: 8px;
  left: 25px;
}
.overlay .close_btn:after {
  display: block;
  content: "";
  width: 1px;
  height: 36px;
  background-color: #fff;
  transform: rotate(-45deg);
  position: absolute;
  top: 8px;
  left: 25px;
}

// トリミング決定ボタン
.overlay .select_icon_btn {
  letter-spacing: normal;
  width: 200px;
  height: 40px;
  line-height: 40px;
  background-color: #C84289;
  color: #fff;
  border-radius: 5px;
  text-align: center;
  box-sizing: border-box;
  position: absolute;
  top: 530px;
  right: 0;
  left: 0;
  margin: 0 auto;
  cursor: pointer;
}

 

controllerのコーディング

users_controller.rb(抜粋)

class UsersController < ApplicationController
  before_action :authenticate_user!
  
  # 省略

  def edit
    @user = User.find(params[:id])
    redirect_to advertisements_path, alert: "お探しのページは表示できません。" unless @user == current_user
  end

  def update
    // format.jsでリダイレクト先を指定
    if current_user.update(set_params)
      respond_to do |format|
        flash[:notice] = "ユーザー情報を更新しました。"
        format.js { render ajax_redirect_to(user_path(current_user.id)) }
      end
    else
      respond_to do |format|
        flash[:danger] = current_user.errors.full_messages
        format.js {render 'layouts/error'}
      end
    end
  end

  def destroy
    @user = current_user
    @user.destroy
    sign_out(@user)
    redirect_to root_path, notice:"アカウント削除完了"
  end

  private

  def set_params
    params.permit(:name, :content, :email, :twitter, :facebook, :icon,
      :icon_cache, :want_to_advertise, :want_to_be_advertised)
  end

  def ajax_redirect_to(redirect_uri)
    { js: "window.location.replace('#{redirect_uri}');" }
  end
end

 

まとめ

当初はajaxなど使わず普通にlocal trueでform送信していたのですが、トリミング機能を付けるためだけにform送信を大幅に変更することになりました。おそらくもっと簡単に実装できる方法もあるのでは無いかと感じてます。

トリミング機能をつければ、こちらが指定したサイズで画像がすべて保存されるため、レイアウトがやりやすいです。

以上ありがとうございました。

返信を残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

CAPTCHA