Zenn プロフィール画像の切り取り (トムケン調べ)

13 min read読了の目安(約12100字

はじめに

zennのこちらのページでは、プロフィール画像を丸く切り取る機能が、実装されています。

こちらと同じようなことを実装したいなと思い、色々試行錯誤した結果、実装することができたので、紹介させていただきたいと思います。
※なお、本記事は、僕自身が調査を行い、こうすると同じようなものが実現できました!ということが分かり、それを記事を書いております。そのため、Zennの内部で実際にどの実装されているのかは分かりませんので、ご了承ください。

【デモンストレーション】プロフィール画像の切り取り

プロフィール画像の切り取りについてのデモンストレーションです。
こちらが、今回実装したものになります。

結論

早速結論ですが、【Cropper.js】というライブラリを使うことで、実現できます。

詳細説明

詳細な説明に入る前に前提として、今回自分は、Nuxt.jsを使って、プロダクト開発を行ったため、Nuxt.jsに関する部分の説明も含まれていることをご了承ください。もし、別のフレームワークを使われている方は、適宜ご自身の環境に置き換えて読んでいただければと思います。

Cropper.jsのインストールと読み込み

はじめに、Cropper.jsをインストールします(yarnかnpmか、もしくは、ご自身の環境に応じて使いわけてください)。

yarn add cropperjs
npm install cropperjs

インストールが完了したら、プロジェクト内でjsファイルと、cssファイルを読み込みます。
ここも、ご自身の環境に置き換えてください。

<script lang="ts">
import Cropper from "cropperjs";
<script>
<style>
@import "cropperjs/dist/cropper.min.css";
</style>

cssの設定

やることとしては、大きく下記2つです。

  1. 画像切り取り部分に関する設定を追加
  2. モーダル上で表示する画像の高さと横幅の比を1対1にする設定を追加

1.画像切り取り部分に関する設定を追加

ここでは、下記記述を追加すれば、OKです。

.cropper-view-box,
.cropper-face {
  border-radius: 50%;
  cursor: grab;
  outline: initial;
}
.cropper-face:active {
  cursor: grabbing;
}

ここでは、下記4点の設定を行っています。

  1. 画像切り取り部分を丸くするための設定
  2. 画像をオーバーマウスした時のカーソルの設定
  3. 画像を選択した時のカーソルの設定
  4. アウトラインの初期化

1.画像切り取り部分を丸くするための設定
Cropper.jsはデフォルトでは、画像を四角く切り取るようになっています。
画像切り取り部分を丸くするための設定に関しては、下記URLを参考にしました。

2.画像をオーバーマウスした時のカーソルの設定
3.画像を選択した時のカーソルの設定
Cropper.jsはデフォルトで「cursor: all-scroll」が設定されています。
カーソルの詳細については、下記URLを参考にしました(画像を選択した時の設定も追加します)。

4.アウトラインの初期化
Cropper.jsはデフォルトで、アウトラインが、青色になっているので、初期化します。

以上を反映させた、上記cssの設定を追加します。
なお、下記で指定している「.cropper-*」というclass属性は、Cropper.jsで利用されているものになるため、ここは、そのままコピペしていただいてOKです。

2.モーダル上で表示する画像の高さと横幅の比を1対1にする設定を追加

<img>タグを用いて選択した画像を表示させるのですが、この<img>タグの高さと横幅の比を1対1にする設定を追加します。
zennでは、高さ(height)、横幅(width)ともに、300pxになるように設定されていました(2021/5/27現在)。
300pxだと、レスポンシブ対応を考えたときに、画面サイズが変更となったときに再レンリングが不要というメリットがありますし、特に問題がなかったため、自分も300pxで設定しました。

実際僕が書いている書き方とは少し違いますが、イメージとしてはこのような感じです。

<img id="cropping-image" :src="fileURL" style="width: 300px; height: 300px;"/>

そうすると、以下の画像のように高さ、横幅ともに、300pxで表示されるようになります。

Cropperの設定

Cropperの設定の説明の前に、モーダルのvueファイルのscriptタグの記述を以下に記載します(一部本記事に不要なところもありますが、ご了承ください)。
※このvueファイルは、componentです。

<script lang="ts">
import Cropper from "cropperjs";
import Vue from "vue";
const zoomRatio = 0.05 as number;
export default Vue.extend({
  props: {
    fileURL: {
      type: String,
      default: null,
    },
  },
  data() {
    return {
      cropper: null as any,
      zoomRangeValue: 0 as number,
    };
  },
  mounted() {
    const croppingImage: HTMLImageElement = <HTMLImageElement>(
      document.getElementById("cropping-image")
    );
    this.cropper = new Cropper(croppingImage, {
      aspectRatio: 1,
      viewMode: 3,
      cropBoxResizable: false,
      cropBoxMovable: false,
      dragMode: "move",
      guides: false,
      center: false,
      highlight: false,
      rotatable: false,
      toggleDragModeOnDblclick: false,
      restore: false,
      zoomOnWheel: false,
      // imgのHeightとWidthに合わせる(正方形にするといい感じに表示される)。
      minCropBoxHeight: 300,
      minCropBoxWidth: 300,
    });
  },
  computed: {
    zoomImage: {
      get(): number {
        return this.zoomRangeValue;
      },
      set(value: number) {
        // 値の大小比較を行う。
        // 変更後の値の方が大きかったらプラス(ズームイン)。
        // 変更後の値の方が小さかったら、プラス(ズームアウト)。
        let beforeValue = this.zoomImage;
        let afterValue = value;

        // なぜか、9と10の比較がうまくできないのでこのような条件分岐で対応。
        if (beforeValue == 9 && afterValue == 10) {
          this.cropper.zoom(zoomRatio);
        } else if (beforeValue == 10 && afterValue == 9) {
          this.cropper.zoom(-zoomRatio);
        } else if (beforeValue < afterValue) {
          this.cropper.zoom(zoomRatio);
        } else if (beforeValue > afterValue) {
          this.cropper.zoom(-zoomRatio);
        }
        // 値をセットする。
        this.zoomRangeValue = value;
      },
    },
  },
  methods: {
    confirmCroppingImage() {
      // 「Cropper」型に一致しているかどうかを確認する。
      if (this.cropper instanceof Cropper) {
        let croppedCanvas = this.cropper.getCroppedCanvas({
          width: 160,
          height: 160,
        });
        let roundedCanvas = this.getRoundedCanvas(croppedCanvas);
        // 元の画面にDataURLを返す。
        this.$emit("imageFileUrl", roundedCanvas.toDataURL());
        // モーダルを閉じる。
        this.$emit("closeTheProfileImageModal");
      }
    },
    getRoundedCanvas(sourceCanvas: HTMLCanvasElement) {
      let canvas: HTMLCanvasElement = document.createElement("canvas");
      if (canvas !== undefined) {
        let context = canvas.getContext("2d");
        let width = sourceCanvas.width;
        let height = sourceCanvas.height;

        canvas.width = width;
        canvas.height = height;

        if (context !== null) {
          context.imageSmoothingEnabled = true;
          context.drawImage(sourceCanvas, 0, 0, width, height);
          context.globalCompositeOperation = "destination-in";
          context.beginPath();
          context.arc(
            width / 2,
            height / 2,
            Math.min(width, height) / 2,
            0,
            2 * Math.PI,
            true
          );
          context.fill();
        }
      }
      return canvas;
    },
  },
});
</script>

Cropperの設定は、以下の部分です。

  mounted() {
    const croppingImage: HTMLImageElement = <HTMLImageElement>(
      document.getElementById("cropping-image")
    );
    this.cropper = new Cropper(croppingImage, {
      aspectRatio: 1,
      viewMode: 3,
      cropBoxResizable: false,
      cropBoxMovable: false,
      dragMode: "move",
      guides: false,
      center: false,
      highlight: false,
      rotatable: false,
      toggleDragModeOnDblclick: false,
      restore: false,
      zoomOnWheel: false,
      // imgのHeightとWidthに合わせる(正方形にするといい感じに表示される)。
      minCropBoxHeight: 300,
      minCropBoxWidth: 300,
    });
  }

ここで、実施していることを説明すると、以下のようになります。
「document.getElementById」を使って、HTMLの<img>タグを取得し、それを「new Cropper」の第一引数に渡して、第二引数で、各種オプションを設定しています。
オプションの詳細な説明は、下記URLをご確認ください(本記事では、詳細の説明は割愛します)。

ここでの一番重要なポイントは、「minCropBoxHeight」「minCropBoxWidth」です。
これらを、「2.モーダル上で表示する画像の高さと横幅の比を1対1にする設定を追加」の部分で設定した「hegiht」と「width」に合わせてください。
そうすることで、画像を切り取る部分が、ウィンドウいっぱいに広がってくれます。

この設定の前後では、次のような違いがあります。
左が設定前、右が設定後になります。

スライダーの実装

スライダーのデザイン

スライダーは、HTML5のinput要素を用いました。
input要素は、下記のように設定しております。

<input type="range" id="range" min="0" max="10" step="1" v-model="zoomImage" />

デザイン部分に関しては、CatNoseさん(zennの開発者)が書いてくださっている、以下の記事を参考にさせていただきました。

基本的には、コピペしています。
変更点としては、色に関わる部分だけです。
一応、自分が書いたソースコードも、下記に記載しておきます。

input[type="range"] {
  -webkit-appearance: none; /* 🚩これ無しだとスタイルがほぼ全く反映されないので注意 */
  appearance: none;
  cursor: pointer; /* カーソルを分かりやすく */
  outline: none; /* スライダーのアウトラインは見た目がキツイので消す */
  height: 14px; /* バーの高さ */
  width: 100%; /* バーの幅 */
  background: #dac4f7; /* バーの背景色 */
  border-radius: 10px; /* バーの両端の丸み */
  border: solid 3px #f1dfff; /* バー周囲の線 */
}
/* WebKit向けのつまみ */
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; /*  🚩デフォルトのつまみのスタイルを解除 */
  background: #671cc9; /* 背景色 */
  width: 24px; /* 幅 */
  height: 24px; /* 高さ */
  border-radius: 50%; /* 円形に */
  box-shadow: 0px 3px 6px 0px rgba(0, 0, 0, 0.15); /* 影 */
}
/* Moz向けのつまみ */
input[type="range"]::-moz-range-thumb {
  background: #671cc9; /* 背景色 */
  width: 24px; /* 幅 */
  height: 24px; /* 高さ */
  border-radius: 50%; /* 円形に */
  box-shadow: 0px 3px 6px 0px rgba(0, 0, 0, 0.15); /* 影 */
  border: none; /* デフォルトの線を消す */
}
/* Firefoxで点線が周りに表示されてしまう問題の解消 */
input[type="range"]::-moz-focus-outer {
  border: 0;
}
/* つまみをドラッグしているときのスタイル */
input[type="range"]:active::-webkit-slider-thumb {
  box-shadow: 0px 5px 10px -2px rgba(0, 0, 0, 0.3);
}

スライダーのイベント

こちらになります。

    zoomImage: {
      get(): number {
        return this.zoomRangeValue;
      },
      set(value: number) {
        // 値の大小比較を行う。
        // 変更後の値の方が大きかったらプラス(ズームイン)。
        // 変更後の値の方が小さかったら、プラス(ズームアウト)。
        let beforeValue = this.zoomImage;
        let afterValue = value;

        // なぜか、9と10の比較がうまくできないのでこのような条件分岐で対応。
        if (beforeValue == 9 && afterValue == 10) {
          this.cropper.zoom(zoomRatio);
        } else if (beforeValue == 10 && afterValue == 9) {
          this.cropper.zoom(-zoomRatio);
        } else if (beforeValue < afterValue) {
          this.cropper.zoom(zoomRatio);
        } else if (beforeValue > afterValue) {
          this.cropper.zoom(-zoomRatio);
        }
        // 値をセットする。
        this.zoomRangeValue = value;
      },

ここでは、スライダーの値が変更されたタイミングで、変更前の値と変更後の値を比較しています。
そして、変更前の値の方が小さかったら、ズームインし、変更前の値の方が大きかったら、ズームアウトしています。

これには、Cropper.jsで用意されている、zoomメソッドを使えば、OKです。
引数で渡している、「zoomRatio」は、今回は0.5としてしています。

このプログラムを見て、ん?あれ?と思った皆さん。はい、お気づきの通りです。
「beforeValue == 9 && afterValue == 10」と「beforeValue == 10 && afterValue == 9」というなんだか不要そうな、条件分岐が書かれていますよね。。。
こちらですが、自分が実装してみたところ、スライダーの値が、9から10に、もしくは、10から9に変わるときの、値の大小比較がうまくできなかたので、追加しています。
おそらく自分がどこかで実装ミスっているだけだと思いますが、、、、、どうしても解決ができなかったので、こうしています。

「確定」ボタンを押した時の処理

「確定」ボタンを押した時の処理ですが、以下のようになります。

<button class="button is-primary" @click="confirmCroppingImage()">
確定
</button>
  methods: {
    confirmCroppingImage() {
      // 「Cropper」型に一致しているかどうかを確認する。
      if (this.cropper instanceof Cropper) {
        let croppedCanvas = this.cropper.getCroppedCanvas({
          width: 160,
          height: 160,
        });
        let roundedCanvas = this.getRoundedCanvas(croppedCanvas);
        // 元の画面にDataURLを返す。
        this.$emit("imageFileUrl", roundedCanvas.toDataURL());
        // モーダルを閉じる。
        this.$emit("closeTheProfileImageModal");
      }
    },
    getRoundedCanvas(sourceCanvas: HTMLCanvasElement) {
      let canvas: HTMLCanvasElement = document.createElement("canvas");
      if (canvas !== undefined) {
        let context = canvas.getContext("2d");
        let width = sourceCanvas.width;
        let height = sourceCanvas.height;

        canvas.width = width;
        canvas.height = height;

        if (context !== null) {
          context.imageSmoothingEnabled = true;
          context.drawImage(sourceCanvas, 0, 0, width, height);
          context.globalCompositeOperation = "destination-in";
          context.beginPath();
          context.arc(
            width / 2,
            height / 2,
            Math.min(width, height) / 2,
            0,
            2 * Math.PI,
            true
          );
          context.fill();
        }
      }
      return canvas;
    },
  },

ここでは、まずCropperの情報から、高さと横幅の比が1対1のcanvas要素を生成します。
その後に、canvas要素を円形で切り取って、DataURLを生成して、それを元のページに渡しています。
そして、最後に、モーダルを閉じます。
ここに載せていませんが、DataURLを受け取った元のページでは、<img>タグのsrc属性にDataURLを設定しています。そうすると、丸く切り取った画像が表示されます。

ここで生成するcanvas要素の高さ(height)と横幅(width)は、モーダルで設定したものと合わせる必要はありません(高さと横幅の比が1対1になっていればOKです)。
また、getRoundedCanvasメソッドは、下記URLを参考にしています。

まとめ

Zennで実装されている、プロフィール画像のトリミング方法について、自分が調べて同じようなものを実装できましたので、それをまとめました。
一番骨が折れたのは、<img>タグの「Height」「Width」と、Cropper「minCropBoxHeight」「minCropBoxWidth」を合わせる必要があるということでした。
ここには、なかなか気づけず、挫けそうになりましたが、無事にこうして、記事にできてよかったです。

プロフィール画像を丸く切り取るというのは、需要が高いのではないかなと思いますので、少しでも、皆さんの参考になれれば幸いです。