プロフィール写真切り取り機能の実装

# はじめに
実務で、プロフィールで顔写真を登録できるようにしたいと要望がありました。

* プロフィール写真は表示回数が多いため、ファイルサイズを小さくする必要がある
* プロフィール写真を表示する際に、ちょうど良くなるように写真を切り取りできるようにする必要がある
* 写真を切り取り後に、プレビューできるようにする
* ユーザーが使いやすいUIにする

上記の点を意識して、実装を行いました。

今回写真切り取り機能を実装するにあたり、以下のQiitaの記事を大いに参考にさせていただきました。

https://qiita.com/yamazaki3104/items/abbcb88f573d3c5a4a40


# 実装サンプル
<p class="codepen" data-height="800" data-default-tab="html,result" data-slug-hash="GReamdK" data-user="Shogo-Watanabe" style="height: 800px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/Shogo-Watanabe/pen/GReamdK">
  Untitled</a> by Shogo Watanabe (<a href="https://codepen.io/Shogo-Watanabe">@Shogo-Watanabe</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

# 実装したコードについて
## ①　写真を選択後、モーダルに画像を表示
「画像を変更する」ボタンを押して、ファイルを選択後、ファイルのブラウザ上でのURLを取得し、モーダルに表示させる。

## ②　画像を切り抜き処理
crop_img()の部分で、画像の切り抜きを行い、JPEG形式に変換

## ③　切り抜いた画像情報を<input type="hidden">を渡す。
toDataURL()でJPEG形式に変換したデータURLを<input type="hidden">に渡し、Controller側で保存することが可能になる。

````javascript
const body = document.querySelector('html body')
  const overlay = document.querySelector('.overlay')
  const modal_img = document.querySelector('#modal_img');
  const imgPreview = document.getElementById('img-preview');
  const imageUrl = document.querySelector('#image_url');
  const scal = document.querySelector('#scal')
  const cancel = document.querySelector('#cancel');
  const crop = document.querySelector('#crop');
  const cvs = document.querySelector('#cvs');
  const cw = cvs.width
  const ch = cvs.height
  const out = document.getElementById( 'out' )
    const oh = out.height
    const ow = out.width

  let ix = 0　// 中心X座標
  let iy = 0　// 中心Y座標
  let v = 1.0　// 拡大率

  /* 同じ画像を再度選択した際にモーダル表示されるようにする */
  $('#form_image').on('click',function(e) {
    e.target.value = '';
  })

  /* 画像選択時にモーダル表示 */
  $('#form_image').on('change', function () {
    const file = $(this).prop('files')[0];
    // ファイルのブラウザ上でのURLを取得する
    const blobUrl = window.URL.createObjectURL(file);
    // img要素に表示
    modal_img.src = blobUrl;

    const img = new Image();
    img.onload = function( _ev ){
      ix = img.width  / 2 
      iy = img.height / 2
      let scl = parseInt( cw / img.width * 100 )
      document.getElementById( 'scal' ).value = scl
      scaling( scl )
    }

    load_img(modal_img.src)


    function load_img( _url ){
      img.src = ( _url ? _url : 'https://1.bp.blogspot.com/-AoQB8vIvlcw/W8BOcXcEQ6I/AAAAAAABPZM/rXNbol90tXcxBZBlXsg__xix03b_F4nqwCLcBGAs/s800/pet_cat_omoi_sleep_man.png' )
    }

    scal.oninput = function(){
      scaling(scal.value);
    }

    function scaling( _v ) {
      v = parseInt( _v ) * 0.008
      draw_canvas( ix, iy )
    }

    /* 画像を更新する */
    function draw_canvas( _x, _y ){
      const ctx = cvs.getContext( '2d' )
      ctx.fillStyle = 'rgb(200, 200, 200)'
      ctx.fillRect( 0, 0, cw, ch )    // 背景を塗る
      ctx.drawImage( img,
        0, 0, img.width, img.height,
        (cw/2)-_x*v, (ch/2)-_y*v, img.width*v, img.height*v,
      )
      ctx.beginPath();
      ctx.strokeStyle = 'rgba(0, 0, 0, 0.8)';
      ctx.arc( cw/2, ch/2, 75, 0, Math.PI * 2, false);
      ctx.stroke();
      // ctx.strokeRect( (cw-ow)/2, (ch-oh)/2, ow, oh ) // 赤い枠
    }

    /* 「決定」ボタンで画像を切り抜き */
    crop.addEventListener('click', () => {
      crop_img();
    })

    /* 「キャンセル」ボタンでモーダルを閉じる */
    cancel.addEventListener('click', () => {
      overlay.classList.add('none');
      // body.style.overflow = 'auto';
    })


    function crop_img(){
      const ctx = out.getContext( '2d' )
      ctx.fillStyle = 'rgb(200, 200, 200)'
      ctx.fillRect( 0, 0, ow, oh )  // 背景を塗る
      ctx.drawImage( img,
        0, 0, img.width, img.height,
        (ow/2)-ix*v*2, (oh/2)-iy*v*2, img.width*v*2, img.height*v*2,
      )
      const img_url = out.toDataURL('image/jpeg' , 0.8);
      imgPreview.src = img_url;
      imageUrl.value = img_url;

      overlay.classList.add('none');
      // body.style.overflow = 'auto';
    }

    overlay.classList.remove('none');
    // body.style.overflow = 'hidden';

    
  let mouse_down = false      // canvas ドラッグ中フラグ
    let sx = 0                  // canvas ドラッグ開始位置
    let sy = 0
    cvs.ontouchstart =
    cvs.onmousedown = function ( _ev ){     // canvas ドラッグ開始位置
        mouse_down = true
    if(_ev.pageX == undefined){
      sx = _ev.touches[0].pageX
    }else{
      sx = _ev.pageX
    }
    if(_ev.pageY == undefined){
      sy = _ev.touches[0].pageY
    }else{
      sy = _ev.pageY
    }
        return false // イベントを伝搬しない
    }
    cvs.ontouchend =
    cvs.onmouseout =
    cvs.onmouseup = function ( _ev ){       // canvas ドラッグ終了位置
        if ( mouse_down == false ) return
        mouse_down = false
    if(_ev.pageX == undefined){
      draw_canvas( ix += (sx - _ev.changedTouches[0].pageX)/v, iy += (sy - _ev.changedTouches[0].pageY)/v )
    }else{
      draw_canvas( ix += (sx-_ev.pageX)/v, iy += (sy-_ev.pageY)/v )
    }
        return false // イベントを伝搬しない
    }
    cvs.ontouchmove =
    cvs.onmousemove = function ( _ev ){     // canvas ドラッグ中
        if ( mouse_down == false ) return
    if(_ev.pageX == undefined){
      draw_canvas( ix + (sx - _ev.touches[0].pageX)/v, iy + (sy - _ev.touches[0].pageY)/v)
    }else{
      draw_canvas( ix + (sx-_ev.pageX)/v, iy + (sy-_ev.pageY)/v )
    }
        return false // イベントを伝搬しない
    }
    cvs.onmousewheel = function ( _ev ){    // canvas ホイールで拡大縮小
        let scl = parseInt( parseInt( document.getElementById( 'scal' ).value ) + _ev.wheelDelta * 0.05 )
        if ( scl < 10  ) scl = 10
        if ( scl > 400 ) scl = 400
        document.getElementById( 'scal' ).value = scl
        scaling( scl )
        return false // イベントを伝搬しない
    }
  });
````

# さいごに
まだ改善する部分があると思いますので、ご指摘いただけますと幸いです。