切り抜き

Web

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は野暮ったさがないので、使いやすアイコンですね。

Nuxt3でセッションを使用したログイン認証機能を作る

headlessuiのdialogを使う

挙動については下記を参考に実装。inputで画像を選択後にモーダルが開いて、モーダル上で画像を切り抜くようにします。

Headless UI
Completely unstyled, fully accessible UI components, designe…

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>

ファイル選択については下記サイトを参考にしました。

label で input[type="file"] を装飾するな

スクリプト部分は以下。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ライブラリで、下記がそのページです。設定のオプションも豊富で、これはかなり使えます。

Cropper.js
JavaScript image cropper.

vue用のライブラリもあり、Nuxt3だったのでこっちの方が簡単に導入できました。

Vue Advanced Cropper
The flexible vue cropper component that gives you the opport…

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に助けてもらいました。

コメント