切り抜き
Nuxt3でheadlessuiとvue advanced cropperを使って画像を切り抜いた
Nuxt3でアバター用の画像を元の画像から切り抜いて作成できないか調べたので、メモ。画像を選択後、モーダルで画像が開き、そこで画像を切り抜く方法を探していると、headlessuiのdialogとcropperjsでできそうだったので、やってみることに。ただ、出来るには出来たのですが、一筋縄でいきませんでした。
使ったものとバージョンは以下です。
"@headlessui/vue": "^1.7.19",
"@heroicons/vue": "^2.1.1",
"@nuxt/image": "^1.5.0",
"nuxt": "^3.10.3",
"vue": "^3.4.19",
"vue-advanced-cropper": "^2.8.8",
ちなみに、headlessuiを使ったのは、以下サイトを参考にした際、headlessuiとheroiconsが気に入ったから。heroiconsは野暮ったさがないので、使いやすアイコンですね。
headlessuiのdialogを使う
挙動については下記を参考に実装。inputで画像を選択後にモーダルが開いて、モーダル上で画像を切り抜くようにします。
html部分はところどころ抜粋していますが、だいたい下記のような形にしました。CSSはTailwindCSSです。標準でないものは、カスタマイズしたものです。後述のcropperjsも設定部分が含まれています。ErrorDialogがアラート用のモーダル。TransitionRoot部分が、画像切り抜き部分のモーダルです。
<div role="button" tabindex="0" class="imageButton block whitespace-nowrap overflow-hidden w-full px-4 py-2 mt-2 text-gray-400 bg-input border border-gray-200 rounded-md focus:border-blue-400 focus:ring-blue-300 focus:ring-opacity-40 focus:outline-none focus:ring">
ファイルを選択
</div>
<input id="imageInput" name="imageInput" type="file" class="hidden" @change="handleFileChange" />
<NuxtImg v-if="croppedImg" :src="croppedImg" alt="Cropped Image" />
<ErrorDialog :isErrorDialog="isErrorOpen" @update:isErrorDialog="isErrorOpen = $event" :message="errorMessage" />
<TransitionRoot :show="isOpen">
<Dialog :open="isOpen" class="fixed inset-0 z-50 overflow-y-auto" @close="setIsOpen">
<div class="flex items-center justify-center min-h-screen">
<TransitionChild enter="duration-150 ease-out" enter-frame="opacity-0" leave="duration-150 ease-in" leave-to="opacity-0">
<DialogOverlay class="fixed inset-0 bg-accent opacity-80" />
</TransitionChild>
<TransitionChild enter="duration-100 ease-out" enter-from="opacity-0 scale-0" enter-to="opacity-50 scale-100" leave="duration-100 ease-in" leave-from="opacity-50 scale-100" leave-to="opacity-0 scale-0">
<div class="relative sm:w-full mx-auto overflow-y-auto bg-white rounded-lg">
<section class="w-full p-6 bg-main">
<button class="absolute top-2.5 right-2.5 -m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700" @click="setIsOpen(false)">
<XMarkIcon class="h-6 w-6 text-white" aria-hidden="true" />
</button>
<hgroup>
<span class="block w-fit mx-auto mb-1 px-1 py-0.5 font-roboto bg-accent text-gold text-[10px]">CROP</span>
<DialogTitle>
<h1 class="mb-4 text-3xl text-center text-white font-noto font-normal">
画像調整
</h1>
</DialogTitle>
<DialogDescription>
<p class="mt-1 text-center text-gray-50 text-sm font-noto font-light">
画像をお好みで調整してください。
</p>
</DialogDescription>
</hgroup>
</section>
<Cropper v-if="selectedImage.value" :imageData="selectedImage.value" :stencil-props="{ aspectRatio: 1 / 1,}" :stencil-size="{ width: 280, height: 280,}" @imageCropped="handleImageCropped" @cropOut="onCropOut" @resetImageData="resetSelectedImage" />
</div>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
ファイル選択については下記サイトを参考にしました。
スクリプト部分は以下。useImageUploadは後述のinput fileのスクリプトと画像情報の取り込みをまとめたものです。あと、画像データの初期化や監視を行わないと、うまくcropperjsに画像情報伝えられなかったので、盛っています。それについては後述しています。
<script lang="ts" setup>
import {
Dialog,
DialogOverlay,
DialogTitle,
DialogDescription,
TransitionRoot,
TransitionChild,
} from '@headlessui/vue';
import { XMarkIcon } from '@heroicons/vue/24/outline';
import useImageUpload from '@/composables/useImageUpload';
import Cropper from '~/components/Cropper.vue';
import type { Ref } from 'vue';
const croppedImg: Ref = ref('');
const selectedImage: Ref = ref('');
const { uploadFile, imgData, isErrorOpen, errorMessage } = useImageUpload();
const isOpen = ref(false);
const setIsOpen = (value: boolean) => {
isOpen.value = value;
if (!value) {
selectedImage.value = '';
}
};
const handleFileChange = (event: Event) => {
// inputでファイルを扱う
uploadFile(event);
if (!isErrorOpen.value) {
setIsOpen(true);
}
};
watchEffect(() => {
if (imgData.value) {
selectedImage.value = imgData;
}
});
const handleImageCropped = (croppedImage: string) => {
croppedImg.value = croppedImage;
};
const onCropOut = () => {
isOpen.value = false;
};
const resetSelectedImage = () => {
selectedImage.value = '';
};
</script>
useImageUpload.tsの中身は以下。画像ファイルが画像の拡張子か判別し、’jpg’, ‘jpeg’, ‘png’, ‘gif’, ‘webp’, ‘svg’以外だったら使えないよというアラートを加えてみました。そのため、ちょっと面倒な仕組みになりました。
// image upload
const useImageUpload = () => {
const uploadDataName = ref();
const fileData = ref();
const imgData = ref();
const isErrorOpen = ref<boolean>(false);
const errorMessage = ref<string>('');
const uploadFile = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
const file = target.files[0];
fileData.value = file;
// 以下アラート対応
const fileName = file.name;
const fileExtension = fileName.split('.').pop()?.toLowerCase();
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
if (fileExtension && imageExtensions.includes(fileExtension)) {
uploadDataName.value = fileName;
// ここはファイルデータの取得
const reader = new FileReader();
reader.onload = (e) => {
imgData.value = e.target?.result as string;
};
reader.readAsDataURL(file);
} else {
errorMessage.value =
'画像ファイルを選択してください。jpg・jpeg・png・gif・webp・svg拡張子が有効です。';
isErrorOpen.value = true;
}
}
};
onMounted(() => {
const imageButton = document.querySelector('.imageButton') as HTMLElement;
imageButton?.addEventListener('click', () => {
(document.querySelector('#imageInput') as HTMLElement)?.click();
console.log('click');
});
imageButton?.addEventListener('keydown', (event) => {
if (!imageButton.isEqualNode(event.target as Node)) {
return;
}
if (event.keyCode === 32 || event.keyCode === 13) {
event.preventDefault();
(document.querySelector('#imageInput') as HTMLElement)?.click();
}
});
watch(uploadDataName, (newValue: string) => {
if (newValue) {
imageButton.innerText = newValue;
}
});
});
return {
uploadFile,
fileData,
imgData,
isErrorOpen,
errorMessage,
};
};
export default useImageUpload;
Cropperjsを使う
Cropperjsは画像を切り抜くjsライブラリで、下記がそのページです。設定のオプションも豊富で、これはかなり使えます。
vue用のライブラリもあり、Nuxt3だったのでこっちの方が簡単に導入できました。
componentsにCropper.vueとして作成しました。使い回すために、外から画像の横幅・縦幅・アスペクト比を外から持ってこれるのは便利ですね。確定を押せば、選択されている画角で切り抜かれます。切り抜かれた画像データはinput fileの下部にあるnuxt imgに送られ、画面に表示されるハズです。
<script lang="ts" setup>
import { CircleStencil, Cropper } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';
import type { Ref } from 'vue';
const props = defineProps({
imageData: String,
'stencil-size': Object,
'stencil-props': Object,
});
type Emits = {
(event: 'imageCropped', value: string): void;
(event: 'cropOut'): void;
(event: 'resetImageData'): void;
};
const emits = defineEmits<Emits>();
const img: Ref = ref('');
img.value = props.imageData;
const getImage = ref<string | null>(null);
const cropperRef = ref();
const coordinates = ref({
width: 0,
height: 0,
left: 0,
top: 0,
});
let crop = () => {};
onMounted(() => {
crop = () => {
const { coordinates: newCordinates, canvas } = cropperRef.value.getResult();
// typeがimage/pngに変換されている
coordinates.value = newCordinates;
getImage.value = canvas.toDataURL();
if (getImage.value !== null) {
emits('imageCropped', getImage.value);
emits('cropOut');
emits('resetImageData');
}
};
});
</script>
<template>
<div class="bg-white">
<cropper
ref="cropperRef"
class="cropper"
:src="img"
:stencil-component="CircleStencil"
:stencil-props="props['stencil-props']"
:stencil-size="props['stencil-size']"
/>
<div class="flex justify-center py-2">
<button
class="px-6 py-2 text-sm font-medium tracking-wide text-white font-noto capitalize transition-colors duration-300 transform bg-accent rounded-full hover:bg-spare focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-50"
@click="crop"
type="button"
>
確定
</button>
</div>
</div>
</template>
<style lang="scss">
.cropper {
height: 300px;
width: 300px;
background: #fff;
}
</style>
一筋縄でいかなかった部分としては、画像を選択すると、1回目は選択した画像がcropperjsに表示されるのですが、2回目以降は一つ古い画像がcropperjsに表示されてしまうのです。console.logを入れてjsの動作順を見ていくと、1回目の画像選択では、useImageUploadの方が早く反応するのですが、2回目以降はCropper.vueの画像部分が早く反応し、新しい画像が間に合っていない感じでした。そのため、あーだこーだやって、画像データを初期化し、その情報を親に返すと、何度画像を選択しても、useImageUploadの方が早く反応し、希望する動作を実現。色々詰まった部分もあったので、copilotに助けてもらいました。