Nuxt.js（Vue.js）でcroppieを使ってサムネイル画像のトリミング機能を実装した

## はじめに

Nuxt.js ベースのプロダクトに、ユーザーが画像をアップロードして、ユーザーアイコン（サムネイル）を切り取って設定するという機能を付けようと思いました。

![ 2021-03-27 14.57.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331607/5969a7a8-7ceb-3350-bd70-4b4e1ded0c9b.png)


ここでの要件は一般的なもので、

1. ユーザーが画像をアップロード（拡張子の制限あり）
1. 画像の範囲を指定して切り取る（トリミング）
1. サーバーに画像をアップロード・サーバー側での処理
1. サムネイルが更新される

というものです

環境は以下の通りです。
- Nuxt.js v2.12.2 (SSR / TypeScript)

私は Nuxt.js ベースでの導入でしたが、 Vue.jsでも本質的には何も変わりません。

## 使用した技術

結論から言うと、新しく導入したパッケージは以下の二つです。


- [croppie](https://www.npmjs.com/package/croppie)
- [@types/croppie](https://www.npmjs.com/package/@types/croppie)

画像トリミングはよく要求される技術なだけあって、すでに `croppie` という素晴らしいプラグインが存在していました。

公式ページは[こちら](https://foliotek.github.io/Croppie/)で、おそらくほとんどの要件・要求に応えてくれるような設計となっています。

また `@types/croppie` は `croppie` の型定義をしてくれるファイルです。Nuxt.js with TypeScript に取り組まれている方にはお馴染みのパッケージですね。

`croppie` が素晴らしいパッケージであることには違いないのですが、公式ドキュメントは native な javascript での使用方法ばかりで、  Vue.js / Nuxt.js でどう使えばいいんだろうというのがあまり見えてきませんでした（少なくとも私には）。

そこで「Vue croppie」などとググるわけですが、そうすると次のパッケージに出会います

- [vue-croppie](https://www.npmjs.com/package/vue-croppie)

「これだ」と思いました。

ですが、実際にパッケージをインストールして使ってみたりプラグインのコードを読んだ上で、私は `vue-croppie` は使わない選択をしました。

その理由は以下の通りです。

- 公式ドキュメントの `Sample` に誤りが散見され、残念な気持ちになる（閉じタグ `</script>` がないなどの基本的な誤り）
- コードをよくみると、型の定義がかなり雑で怖い
- Nuxt.jsのSSRに対応していない（少なくとも私の環境では一発でエラーが出ました）
- そもそも、中身がほとんど `croppie` そのものなので、自分で書き直せる

以下のように、SSRだと`window`が`undefined`だとのエラーが出てしまいます。
![ 2021-03-26 20.58.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331607/cda3f072-9b4b-569e-4af3-123d3e37f385.png)


以上のような理由で、


① 以下の二つのパッケージをインストールして、

- [croppie](https://www.npmjs.com/package/croppie)
- [@types/croppie](https://www.npmjs.com/package/@types/croppie)

② `vue-croppie`のアイデアを参考にしつつ、自分でコンポーネントを作成する


という方向性に決めました。

※ただし、Vue.js　(あるいはNuxt.jsのSPA)を使用していて、かつ型も気にならないと言う場合は、さくっと `vue-croppie` をインストールして使ってみるのも良いかもしれません（未検証です）。この場合は本記事は役に立たないかも知れません。


## 構成

ということで、`vue-croppie`をリスペクトしつつ、なるべく型を失わないようなコンポーネントを作りました。

コンポーネントの構成は以下の通りです。

- `components/atoms/ACrop.vue`
- `components/organisms/OCrop.vue`

`ACrop.vue` は `croppie` を Vue 向けに使用できるようにしたコンポーネントで、この記事の本質と言えるでしょう。

`OCrop.vue` はその `ACrop.vue` を呼び出すコンポーネントです。`OCrop.vue` にも必要な記述は多々ありますが、コンポーネントである必要はありません。`pages/` 以下で直接 `ACrop.vue` を呼び出してもらっても大丈夫です。

## 作ってみた

まずは、パッケージのインストールからいきましょう。

```
$ yarn add croppie @types/croppie
```

あとは、コンポーネントを作ってしまうだけです。

```ACrop.vue
<template>
  <!-- ref="ACrop" は必須です. 名前はACropである必要はありません -->
  <div id="ACrop" ref="ACrop" :class="['a--crop', customClass]" />
</template>

<script lang="ts">
// デフォルトのCSS, croppieと型定義を読み込みます
import 'croppie/croppie.css'
import Croppie, { CroppieOptions } from 'croppie'
import Vue, { PropType } from 'vue'

interface BindOptions {
  url: string
  points?: number[]
  orientation?: number
  zoom?: number
  useCanvas?: boolean
}

interface Data {
  model: Croppie | null
}

export default Vue.extend({
  name: 'ACrop',

  // propsの型や初期値はcroppieの公式ドキュメントに準拠しています
  props: {
    boundary: {
      type: Object as PropType<CroppieOptions['boundary']>,
      default: null,
    },
    customClass: {
      type: String,
      default: '',
    },
    enableExif: {
      type: Boolean,
      default: false,
    },
    enableOrientation: {
      type: Boolean,
      default: false,
    },
    enableResize: {
      type: Boolean,
      default: false,
    },
    enableZoom: {
      type: Boolean,
      default: true,
    },
    enforceBoundary: {
      type: Boolean,
      default: true,
    },
    mouseWheelZoom: {
      type: [Boolean, String] as PropType<CroppieOptions['mouseWheelZoom']>,
      default: true,
    },
    showZoomer: {
      type: Boolean,
      default: true,
    },
    viewport: {
      type: Object as PropType<CroppieOptions['viewport']>,
      default: () => ({ width: 100, height: 100, type: 'square' }),
    },
    minZoom: {
      type: Number,
      default: 0,
    },
    maxZoom: {
      type: Number,
      default: 1.5,
    },
  },

  data: (): Data => ({
    model: null,
  }),

  mounted(): void {
    // mounted()の中で、new Croppieを呼び出します
    this.initialize()
  },

  methods: {
    initialize(): void {
      // DOMを読み込みます
      const el = this.$refs.ACrop as HTMLElement

      const options = {
        enableExif: this.enableExif,
        enableOrientation: this.enableOrientation,
        enableZoom: this.enableZoom,
        enableResize: this.enableResize,
        enforceBoundary: this.enforceBoundary,
        mouseWheelZoom: this.mouseWheelZoom,
        viewport: this.viewport,
        showZoomer: this.showZoomer,
        minZoom: this.minZoom,
        maxZoom: this.maxZoom,
      } as CroppieOptions

      if (this.boundary !== null) {
        options.boundary = this.boundary
      }

      // Croppieモデルを作ります
      this.model = new Croppie(el, options)
    },

    // 以下の関数は親コンポーネント（OCrop.vue）から呼び出されます
    // croppieの公式ドキュメントに準拠しています

    bind(options: BindOptions): Promise<void> {
      return (this.model as Croppie).bind(options)
    },
    setZoom(zoom: number): void {
      ;(this.model as Croppie).setZoom(zoom)
    },
    rotate(degrees: 90 | 180 | 270 | -90 | -180 | -270): void {
      ;(this.model as Croppie).rotate(degrees)
    },
    get(): Croppie.CropData {
      return (this.model as Croppie).get()
    },
    result(options: Croppie.ResultOptions): Promise<HTMLCanvasElement> {
      return (this.model as Croppie).result(options).then(output => output)
    },
    destroy(): void {
      ;(this.model as Croppie).destroy()
    },
    refresh(): void {
      this.destroy()
      this.initialize()
    },
  },
})
</script>

<style lang="scss" scoped>
.a--crop /deep/ {
  // デフォルトのCSSを上書きすることができます
  // お好みに合わせてどうぞ

  // スライダーの横幅
  .cr-slider-wrap {
    width: 100%;
  }

  // スライダーのデザイン
  .cr-slider[type='range'] {
    appearance: none;
    background-color: #fff;
    height: 5px;
    width: 100%;
    border-radius: 6px;

    &:focus,
    &:active {
      outline: none;
    }

    &::-webkit-slider-thumb {
      appearance: none;
      cursor: pointer;
      position: relative;
      margin-top: -8px;
      border: 2px solid #0c948d;
      width: 22px;
      height: 22px;
      display: block;
      background-color: #fff;
      border-radius: 50%;
    }

    &:active::-webkit-slider-thumb {
      box-shadow: 0 0 0 2px rgba(#4dbac4, 0.1);
      transition: 0.3s ease;
    }
  }
}
</style>

```

```OCrop.vue
<template>
  <div class="o--crop">
    <div class="o--crop__wrapper">
      <div>
        <!-- ref="OCrop"は必須 -->
        <a-crop
          ref="OCrop"
          enable-orientation
          :boundary="{ width: 150, height: 150 }"
          :viewport="{ width: 100, height: 100, type: 'circle' }"
        />
      </div>

      <!-- この後ろはボタン類などの飾り・非本質 -->
      <div class="o--crop__items">
        <label for="o--crop__input" class="o--crop__label mb-16">
          画像をアップロード
          <input
            id="o--crop__input"
            class="o--crop__input"
            type="file"
            :accept="allowedExtensions.join(',')"
            @change="handleChange"
          />
        </label>

        <div class="flex align-center mb-16">
          <span class="mr-12">画像の回転:</span>
          <fa class="o--crop__rotate undo mr-4" :icon="faUndo" @click="handleRotate(90)" />
          <fa class="o--crop__rotate redo" :icon="faRedo" @click="handleRotate(-90)" />
        </div>

        <div class="flex align-center">
          <span class="mr-12">リセット:</span>
          <div class="o--crop__reset" @click.prevent="handleRefresh">初めに戻す</div>
        </div>
      </div>
    </div>

    <button @click.prevent="handleCrop">切り取る</button>
    <button @click.prevent="handleClick">更新</button>
  </div>
</template>

<script lang="ts">
import { ResultOptions } from 'croppie'
import { faRedo, faUndo } from '@fortawesome/free-solid-svg-icons'
import ACrop from '../atoms/ACrop.vue'
import Vue from 'vue'

interface HTMLInputEvent extends Event {
  target: HTMLInputElement & EventTarget
}

interface Data {
  croppedImage: string | null
  allowedExtensions: string[]
  initialZoomPoint: number
  faRedo: typeof faRedo
  faUndo: typeof faUndo
  submitting: boolean
}

export default Vue.extend({
  name: 'OCrop',

  components: {
    ACrop,
  },

  data: (): Data => ({
    croppedImage: null,
    allowedExtensions: ['image/jpeg', 'image/png'],
    initialZoomPoint: 0, // 初期状態でのスライダーの位置
    faRedo,
    faUndo,
    submitting: false,
  }),

  mounted(): void {
    this.initialize()
  },

  methods: {
    initialize(): void {
      // 一番最初にセットする画像のURL / 本来ならばユーザーのサムネイルなど
      const url = 'http://i.imgur.com/fHNtPXX.jpg'

      ;(this.$refs.OCrop as any)
        .bind({
          url,
        })
        .then(() => {
          // ここでスライダーの初期値をセットする（bindのあとにセットするのがポイント）
          ;(this.$refs.OCrop as any).setZoom(this.initialZoomPoint)
        })
    },
    handleChange(e: HTMLInputEvent): void {
      const files = e.target.files
      if (!files || !files.length) return

      const reader = new FileReader()
      reader.onload = (ev: ProgressEvent<FileReader>) => {
        ;(this.$refs.OCrop as any).bind({
          url: ev.target?.result as string,
        })
      }
      reader.readAsDataURL(files[0])
    },
    handleCrop(): void {
      const options = {
        type: 'base64',
        size: { width: 300, height: 300 },
        format: 'jpeg',
      } as ResultOptions

      ;(this.$refs.OCrop as any).result(options).then((output: string) => this.handleResult(output))
    },
    handleResult(output: string): void {
      this.croppedImage = output

      // 親要素に result イベントを発火する（任意）
      this.$emit('result', this.croppedImage)
    },
    handleRotate(degree = 90): void {
      // 画像の回転
      ;(this.$refs.OCrop as any).rotate(degree)
    },
    handleRefresh(): void {
      // 初期状態に戻す
      ;(this.$refs.OCrop as any).refresh()
      this.initialize()
    },
    handleClick(): void {
      // ここにaxiosなどのサーバーへの処理を書いたら良いでしょう！
    },
  },
})
</script>

<style lang="scss" scoped>
.o--crop {
  &__wrapper {
    display: flex;
    justify-content: space-evenly;
    align-items: center;
    flex-direction: column;
    margin-bottom: 48px;
  }

  &__label > &__input {
    display: none;
  }

  &__label {
    padding: 1rem 1.5rem;
    color: #fff;
    background: #0c948d;
    border-radius: 4px;
    cursor: pointer;
  }

  &__items {
    display: flex;
    flex-direction: column;
  }

  &__rotate {
    font-size: 2rem;
    color: #0c948d;
    padding: 4px;
    cursor: pointer;
  }

  &__reset {
    border: 1px solid #bbb;
    color: colors(black);
    background: #fafafa;
    padding: 2px 8px;
    border-radius: 4px;
    font-size: 0.9rem;
    cursor: pointer;
  }
}
</style>
```


## 終わりに

`as any`で型キャストした点は少しサボってしまいました。非本質的な飾りの部分についてはCSSやHTMLを一部省略している箇所があります。

今回の機能は[資格TimesのQA掲示板](https://shikakutimes.jp/qa)のために実装しました。資格TimesのQA掲示板は日本初の資格特化のQA掲示板です。もしよろしければ覗いてみてください😀