✂️

Reactでアップロードした画像を切り取る:コンポーネントとカスタムフックの実装ガイド

2024/07/05に公開

はじめに

株式会社メンヘラテクノロジーでエンジニアをしているandmohikoです。

SNSなどでプロフィール画像を設定する際、選択した画像をそのままのサイズで設定すると見づらくなることがあります。そのため、画像を設定したいサイズに事前に加工してからアップロードした経験がある方も多いでしょう。また、画像を事前に加工する手間を嫌い、サイトから離れたこともあるかもしれません。このような問題を解決するために、Reactで画像のクロップ機能を実装しました。

この記事では、選択した画像のトリミングをしてアップロードする機能の実装方法を解説していきます。具体的なコンポーネントとカスタムフックの実装手順を説明し、実際のPull Requestのリンクも載せてています。

今回実装したもの

今回の実装のプルリクエストはこちらです。
https://github.com/andmohiko/mantine-atelier/pull/77

ソースコードの全体はこちらから見れます。他にも便利なコンポーネントを作成しているのでよかったら見てみてください。

使用する技術

Reactで画像をクロップするライブラリとして、今回はreact-image-cropを使用します。
https://www.npmjs.com/package/react-image-crop

ファイルのアップロードはMantineDropzoneコンポーネントを使用します。

ライブラリのversionはReact v18.2.0, @mantine/dropzone v7.10.1, react-image-crop v11.0.5を使用しています。

ライブラリのインストール

ベースのプロジェクトのセットアップは済んでいるものとして進めます。
Mantineの導入については公式のGetting startedに従って進めてください。

必要なライブラリをインストールします。

$ yarn add @mantine/dropzone react-image-crop

@mantine/dropzoneのスタイルを_app.tsxでimportしておきます。

_app.tsx
+ import '@mantine/dropzone/styles.css'

ファイルアップロードのコンポーネントの実装

まずはフォームのスキーマを定義します。input type fileでは画像を複数選択することもできまずが、選択した画像をクロップするため、今回はファイルを1つだけ受け付けるようにします。こちらではzodを使用しています。

types.ts
const FileUploadSchema = z.object({
  file: z.string().min(1, 'ファイルを選択してください'),
})

type FileUploadInputType = z.infer<typeof FileUploadSchema>

次に、ファイルをアップロードするコンポーネントを実装します。こちらはMantineのDropzoneコンポーネントをそのまま使用しています。
inputなコンポーネントなのでpropsはvalue, onChange, errorにすることで使い勝手を他のコンポーネントと揃えています。

FileInputWithCropper.tsx
type Props = {
  value: FileObject
  onChange: (file: FileObject | undefined) => void
  error: string | undefined
}

export const FileInputWithCropper = ({
  value,
  onChange,
  error,
}: Props): React.ReactNode => {
  return (
    <Dropzone
      onDrop={() => {
        console.log('onDrop')
      }}
      onReject={() => {
        console.log('onReject')
      }}
      maxSize={100 * 1024 ** 2}
      accept={IMAGE_MIME_TYPE}
      className={styles.dropzone}
      disabled={isDisabled || isLoading}
    >
      <FlexBox gap={16} justify="center">
        <Dropzone.Accept>
          <AiOutlineUpload color="#777" size={50} />
        </Dropzone.Accept>
        <Dropzone.Reject>
          <RxCross1 color="#777" size={50} />
        </Dropzone.Reject>
        <Dropzone.Idle>
          <MdOutlineAddPhotoAlternate color="#777" size={50} />
        </Dropzone.Idle>
        {isLoading && <LoadingOverlay visible />}
      </FlexBox>
      {isDisabled && <Overlay color="#fff" opacity={0.7} />}
    </Dropzone>
  )
}

さきほど定義したスキーマと、こちらのコンポーネントを組み合わせてフォームを作成します。フォームバリデーションライブラリにはReact Hook Formを使用しています。

ImageCropForm.tsx
export const ImageCropForm = (): React.ReactNode => {
  const {
    handleSubmit,
    control,
    formState: { errors, isSubmitting, isValid },
  } = useForm<FileUploadInputType>({
    resolver: zodResolver(FileUploadSchema),
    mode: 'all',
    defaultValues: {
      file: undefined,
    },
  })

  const onSubmit = (data: FileUploadInputType) => {
    console.log('submit', {
      ...data,
    })
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <FlexBox gap={40}>
        <Controller
          name="file"
          control={control}
          render={({ field }) => (
            <FileInputWithCropper
              value={field.value}
              onChange={field.onChange}
              error={errors.file?.message}
            />
          )}
        />
        <BasicButton type="submit" loading={isSubmitting} disabled={!isValid}>
          保存する
        </BasicButton>
      </FlexBox>
    </form>
  )
}

カスタムフックの実装

コンポーネントのI/Oが固まったので、次はファイルアップロードとクロップをするカスタムフックを実装していきます。
カスタムフック内でファイルを取り回す型はFileObjectと定義していますが、他にも必要な情報があれば拡張してください。

useCropImageInput というカスタムフックを作成します。フックの引数にはfileとsetFileを受け取ります。こちらはフォームから受け取るvalueonChangeをそのまま渡すイメージです。
フックの返り値が多いため、[values, handlers, states]という形にまとめました。

useCropImageInput.ts
type FileUrl = string
export type FileObject = FileUrl

export const useCropImageInput = (
  file: FileObject | undefined,
  setFile: (file: FileObject | undefined) => void,
): [
  // values
  {
    file: FileObject | undefined
    uncroppedImageUrl: string | undefined
    crop: Crop
  },
  // handlers
  {
    onSelectImage: (files: Array<File>) => void
    remove: () => void
    onChangeCrop: (crop: Crop) => void
    onCrop: () => void
    closeCropper: () => void
  },
  // states
  {
    isOpenCropper: boolean
    isDisabled: boolean
    isLoading: boolean
  },
] => {

それではカスタムフックの中身を実装していきます。

まずはカスタムフック内のステートです。今後の実装でそれぞれの役割を説明します。

useCropImageInput.ts
const [isLoading, setIsLoading] = useState<boolean>(false)
const isDisabled = Boolean(file)
const [isOpen, handlers] = useDisclosure()
const [fileData, setFileData] = useState<File | undefined>()
const [uncroppedImageUrl, setUncroppedImageUrl] = useState<
  string | undefined
>()
const [crop, setCrop] = useState<Crop>({
  unit: 'px',
  x: 0,
  y: 0,
  width: 200,
  height: 200,
})

次に、ファイルの選択とクロップ画面を開くところを実装します。
UI側に渡す関数を用意し、UI側で操作が行われたことを起点に発火するuseEffectを用意します。

useCropImageInput.ts
// dropzoneのonDropに渡す関数
const onSelectImage = useCallback((inputFiles: Array<FileWithPath>): void => {
  if (!inputFiles || inputFiles.length === 0) {
    return
  }
  const file = inputFiles[0]
  setFileData(file)
}, [])

// onDropでfileが選択されると発火し、クロップするためのモーダルを開きます
useEffect(() => {
  if (fileData instanceof File) {
    uncroppedImageUrl && URL.revokeObjectURL(uncroppedImageUrl)
    setUncroppedImageUrl(URL.createObjectURL(fileData))
    handlers.open()
  } else {
    setUncroppedImageUrl(undefined)
  }
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileData])

続いて、クロップ時に使用するメソッドの実装です。実際に画像をクロップする処理はcreateCroppedImageUrl内で行っています。こちらの処理はcanvas上で行うため、canvasで扱えるように選択した画像のimgのHTMLElementを作成しています。

useCropImageInput.ts
// canvasで画像を扱うため、アップロードした画像のuncroppedImageUrlをもとに、imgのHTMLElementを作る
const loadImage = (src: string): Promise<HTMLImageElement> => {
  return new Promise((resolve) => {
    const img: HTMLImageElement = document.createElement('img')
    img.src = src
    img.onload = () => resolve(img)
  })
}

// 切り取った画像のObjectUrlを作成し、フォームに保存する
const createCroppedImageUrl = async () => {
  if (uncroppedImageUrl) {
    const img = await loadImage(uncroppedImageUrl)
    const scaleX = img.naturalWidth / img.width
    const scaleY = img.naturalHeight / img.height

    const canvas = document.createElement('canvas')
    canvas.width = crop.width
    canvas.height = crop.height
    const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
    ctx.beginPath()
    ctx.arc(
      canvas.width / 2,
      canvas.height / 2,
      canvas.width / 2,
      0,
      2 * Math.PI,
      false,
    )
    ctx.clip()

    ctx.drawImage(
      img,
      crop.x * scaleX,
      crop.y * scaleY,
      crop.width * scaleX,
      crop.height * scaleY,
      0,
      0,
      crop.width,
      crop.height,
    )

    canvas.toBlob((result) => {
      if (result instanceof Blob) {
        setFile(URL.createObjectURL(result))
      }
    })
  }
}

最後に、こちらのcreateCroppedImageUrlをUI側で呼べるようにラップしたメソッドを用意します。ローディングを入れている理由は、クロップした画像をオブジェクトストレージなどに保存することが考えられるためです。

useCropImageInput.ts
// UI側でクロップ範囲を確定したときに発火する関数
const onCrop = () => {
  setIsLoading(true)
  createCroppedImageUrl()
  handlers.close()
  setIsLoading(false)
}

画像のクロップのコンポーネントの実装

カスタムフックが実装できたので、こちらのフックを使用して画像をクロップするUIを作成します。
react-image-cropが提供しているReactCropコンポーネントを使用しています。

画像のクロップはモーダル上で行うため、ReactCropをモーダルでラップしています。

ImageCropForm.tsx
{uncroppedImageUrl && (
  <ActionModal
    isOpen={isOpenCropper}
    onClose={closeCropper}
    onSave={onCrop}
    title="画像を編集"
  >
    <ReactCrop
      crop={crop}
      onChange={(c) => onChangeCrop(c)}
      aspect={1}
      circularCrop={true}
      keepSelection={true}
    >
      <img
        src={uncroppedImageUrl}
        alt=""
        style={{
          height: '100%',
          width: '100%',
          maxHeight: 400,
        }}
      />
    </ReactCrop>
  </ActionModal>
)}

これでメインの実装は完了です。
最後にクロップした画像の表示などを実装すると使いやすいかもしれません。

おわりに

今回の記事では、Reactを用いて画像クロップ機能を実装する方法について解説しました。コンポーネントとカスタムフックを用意することで拡張性も高く作れたと思います。

さらに深く理解するために、実際のPull RequestやGitHubリポジトリを載せているので、実装の詳細についてはそちらをご確認ください。

画像クロップ機能は、ユーザーが画像を自由に調整できるようにするための重要な要素です。この機能を実装することで、ユーザー体験を向上させられるように皆様のプロジェクトに役立ててください。

参考文献

Discussion