はじめに
Nuxt.js ベースのプロダクトに、ユーザーが画像をアップロードして、ユーザーアイコン(サムネイル)を切り取って設定するという機能を付けようと思いました。
ここでの要件は一般的なもので、
- ユーザーが画像をアップロード(拡張子の制限あり)
- 画像の範囲を指定して切り取る(トリミング)
- サーバーに画像をアップロード・サーバー側での処理
- サムネイルが更新される
というものです
環境は以下の通りです。
- Nuxt.js v2.12.2 (SSR / TypeScript)
私は Nuxt.js ベースでの導入でしたが、 Vue.jsでも本質的には何も変わりません。
使用した技術
結論から言うと、新しく導入したパッケージは以下の二つです。
画像トリミングはよく要求される技術なだけあって、すでに croppie
という素晴らしいプラグインが存在していました。
公式ページはこちらで、おそらくほとんどの要件・要求に応えてくれるような設計となっています。
また @types/croppie
は croppie
の型定義をしてくれるファイルです。Nuxt.js with TypeScript に取り組まれている方にはお馴染みのパッケージですね。
croppie
が素晴らしいパッケージであることには違いないのですが、公式ドキュメントは native な javascript での使用方法ばかりで、 Vue.js / Nuxt.js でどう使えばいいんだろうというのがあまり見えてきませんでした(少なくとも私には)。
そこで「Vue croppie」などとググるわけですが、そうすると次のパッケージに出会います
「これだ」と思いました。
ですが、実際にパッケージをインストールして使ってみたりプラグインのコードを読んだ上で、私は vue-croppie
は使わない選択をしました。
その理由は以下の通りです。
- 公式ドキュメントの
Sample
に誤りが散見され、残念な気持ちになる(閉じタグ</script>
がないなどの基本的な誤り) - コードをよくみると、型の定義がかなり雑で怖い
- Nuxt.jsのSSRに対応していない(少なくとも私の環境では一発でエラーが出ました)
- そもそも、中身がほとんど
croppie
そのものなので、自分で書き直せる
以下のように、SSRだとwindow
がundefined
だとのエラーが出てしまいます。
以上のような理由で、
① 以下の二つのパッケージをインストールして、
② 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
あとは、コンポーネントを作ってしまうだけです。
<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>
<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掲示板のために実装しました。資格TimesのQA掲示板は日本初の資格特化のQA掲示板です。もしよろしければ覗いてみてください😀