👻

[Next.js]ユーザーが画像をアップロードするときにトリミングしてほしい。ついでにサイズを縮小したい。

2023/06/13に公開

やること

  1. ユーザーが画像をinputしたときにダイアログが表示される
  2. ダイアログ上で画像をトリミングする
  3. サイズを縮小する
  4. OKボタンを押す

環境

Next.js:13.4
react-cropper:2.3.3
browser-image-compression:2.0.2
また、状態管理をしたほうがコードの見通しが良いのでjotaiを使用しています。
jotai:2.1.0
ダイアログにshadcnを使用しています。
Dialog - shadcn/ui
npx shadcn-ui add dialog

トリミングするためのダイアログを実装する

中身はシンプルです。
Dialog - shadcn/uiを使用しています。
inputタグがトリガーとなっており、画像が入力されるとsetIsOpen(true)となり、ダイアログが開きます。

<input
  type="file"
  onInput={handleInput}
</input
  // ファイルを選択したときの動作
  const handleInput = async (e: React.ChangeEvent<HTMLInputElement>) => {
    e.preventDefault();
    if (!e.target.files) return;
    setImage(e.target.files[0]);
    setIsOpen(true);
  };
  return (
    <Dialog open={isOpen} onOpenChange={handleOpenChange}>
      {/* ダイアログ用のボタン */}
      <DialogTrigger asChild>
  (省略)

現時点のCropperDialogは以下の通りです。

export const CropperDialog = () => {
  const [isOpen, setIsOpen] = React.useState(false);
  const [image, setImage] = React.useState<File>();

  // ダイアログを閉じたときの動作
  const handleClose = () => {
    setIsOpen(false);
    setImage(undefined);
  };

  // ダイアログを開いたときの動作
  const handleOpenChange = (bool: Boolean) => {
    if (!bool) handleClose();
    if (bool && !image) setIsOpen(false);
    if (bool && image) setIsOpen(true);
  };

  // ファイルを選択したときの動作
  const handleInput = async (e: React.ChangeEvent<HTMLInputElement>) => {
    e.preventDefault();
    if (!e.target.files) return;
    setImage(e.target.files[0]);
    setIsOpen(true);
  };
  
  return (
    <Dialog open={isOpen} onOpenChange={handleOpenChange}>
      {/* ダイアログ用のボタン */}
      <DialogTrigger asChild>
        <label className="block">
          <span className="sr-only">Choose profile photo</span>
          <input
            type="file"
            className="block w-full text-sm text-slate-500 file:mr-4 file:rounded-full file:border-0 file:bg-violet-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-violet-700 hover:file:bg-violet-100"
            id="image"
            accept="image/*"
            placeholder="画像"
            onInput={handleInput}
          />
        </label>
      </DialogTrigger>

      {/* ダイアログ本体 */}
      <DialogContent className="sm:max-w-[425px]">
        {/* ダイアログのヘッダー */}
        <DialogHeader>
          <DialogTitle>画像をトリミングする</DialogTitle>
          <DialogDescription>
            枠線に合わせて画像をトリミングしてください。
          </DialogDescription>
        </DialogHeader>
        {/* ダイアログのコンテンツ */}
        <div>
          ここにダイアログを表示する
        </div>
        {/* ダイアログのフッター */}
        <DialogFooter className="gap-y-4">
          <Button
            type="button"
            variant="outline"
            onClick={async () => handleClose()}
          >
            キャンセル
          </Button>
          <Button
            type="button"
            onClick={async () => {handleClose()}}
          >
            決定する
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

cropperを実装する

react-cropper - npmを実装します。

importに以下を追加します。
必須です。ないとレイアウトが崩れます。

import "cropperjs/dist/cropper.css";

refを用意します。

  const cropperRef = createRef<ReactCropperElement>();

以下のようにcropperを用意します。

srcにはURLを用意します。
inputで入力されたimageをURL.createObjectURL(image)でurlにしています。

aspectRatioで比率を調整します。
aspectRatio={16 / 9}

{/* ダイアログのコンテンツ */}
        <div className="">
          {image && (
            <Cropper
              src={image && URL.createObjectURL(image)}
              ref={cropperRef}
              aspectRatio={16 / 9}
              guides={true}
              viewMode={1}
              minCropBoxHeight={10}
              minCropBoxWidth={10}
              background={false}
              responsive={true}
              checkOrientation={false} // https://github.com/fengyuanchen/cropperjs/issues/671
              autoCropArea={1}
            ></Cropper>
          )}
        </div>

決定ボタンを押したときに、トリミングした画像を取得します。
await getResizeAndTrimmedImage();を追加します。

          <Button
            type="button"
            onClick={async () => {
              await getResizeAndTrimmedImage();
              handleClose();
            }}
          >
            決定する
          </Button>

getResizeAndTrimmedImage()の実装は以下の通りです。
cropperRefをcanvas -> dataURL -> blob -> File型に変換しています。

同時に圧縮しています。
maxSizeMB:1で最大サイズを1MBに設定できます。
でもこの設定だけだと圧縮されないことがあるのでmaxWidthOrHeightで値を指定しています。

  const getTrimmedImage = async () => {
  // トリミング
    if (!cropperRef.current) return;
    const canvas = cropperRef.current.cropper.getCroppedCanvas();
    const dataURL = canvas.toDataURL();
    const file = await convertDataUrlToFile(dataURL, "image.png", "image/png");
 // リサイズ
   const resizedImage = await imageCompression(file, {
    maxSizeMB: 1,
    maxWidthOrHeight: 2028,
  });
 
    setFile(resizedImage);
  };

convertDataUrlToFile.tsは以下の通りです。
そのまんまです。dataUrlを引き取ってFile型をかえしています。

export const convertDataUrlToFile = async (
  dataURL: string,
  filename: string,
  type: "image/png" | "image/jpeg"
): Promise<File> => {
  const blob = await (await fetch(dataURL)).blob();
  return new File([blob], filename, { type: type });
};

GCSにアップロードしています。
1MB以下に収まってますね。

注意点として、この圧縮ライブラリは結構時間かかります。利用者の端末スペックが低ければなおのことです。ですので、環境によっては圧縮する場所を工夫したり、圧縮中はローディングスピナーを出すなどの工夫が必要になります。

トリミングダイアログ完成時点でのコード

export const CropperDialog = () => {
  const [isOpen, setIsOpen] = React.useState(false);
  const [image, setImage] = React.useState<File>();
  const cropperRef = createRef<ReactCropperElement>();
  const setFile = useSetAtom(cropperImageAtom);

  // ダイアログを閉じたときの動作
  const handleClose = () => {
    setIsOpen(false);
    setImage(undefined);
  };

  // ダイアログを開いたときの動作
  const handleOpenChange = (bool: Boolean) => {
    if (!bool) handleClose();
    if (bool && !image) setIsOpen(false);
    if (bool && image) setIsOpen(true);
  };

  // ファイルを選択したときの動作
  const handleInput = async (e: React.ChangeEvent<HTMLInputElement>) => {
    e.preventDefault();
    if (!e.target.files) return;
    setImage(e.target.files[0]);
    setIsOpen(true);
  };

  // トリミングした画像を取得する
  const getTrimmedImage = async () => {
    if (!cropperRef.current) return;
    const canvas = cropperRef.current.cropper.getCroppedCanvas();
    const dataURL = canvas.toDataURL();
    const file = await convertDataUrlToFile(dataURL, "image.png", "image/png");
    setFile(file);
  };

  return (
    <Dialog open={isOpen} onOpenChange={handleOpenChange}>
      {/* ダイアログ用のボタン */}
      <DialogTrigger asChild>
        <label className="block">
          <span className="sr-only">Choose profile photo</span>
          <input
            type="file"
            className="block w-full text-sm text-slate-500 file:mr-4 file:rounded-full file:border-0 file:bg-violet-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-violet-700 hover:file:bg-violet-100"
            id="image"
            accept="image/*"
            placeholder="画像"
            onInput={handleInput}
          />
        </label>
      </DialogTrigger>

      {/* ダイアログ本体 */}
      <DialogContent className="sm:max-w-[425px]">
        {/* ダイアログのヘッダー */}
        <DialogHeader>
          <DialogTitle>画像をトリミングする</DialogTitle>
          <DialogDescription>
            枠線に合わせて画像をトリミングしてください。
          </DialogDescription>
        </DialogHeader>
        {/* ダイアログのコンテンツ */}
        <div className="">
          {image && (
            <Cropper
              src={image && URL.createObjectURL(image)}
              ref={cropperRef}
              aspectRatio={16 / 9}
              guides={true}
              viewMode={1}
              minCropBoxHeight={10}
              minCropBoxWidth={10}
              background={false}
              responsive={true}
              checkOrientation={false} // https://github.com/fengyuanchen/cropperjs/issues/671
              autoCropArea={1}
            ></Cropper>
          )}
        </div>
        {/* ダイアログのフッター */}
        <DialogFooter className="gap-y-4">
          <Button
            type="button"
            variant="outline"
            onClick={async () => handleClose()}
          >
            キャンセル
          </Button>
          <Button
            type="button"
            onClick={async () => {
              await getTrimmedImage();
              handleClose();
            }}
          >
            決定する
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

ページの実装

ダイアログを使用したページを作成します。

export const cropperImageAtom = atom<File | undefined>(undefined);

export const Page = () => {
  const cropperImage = useAtomValue(cropperImageAtom);
  
  const handleClick = async () => {
    // 略
  }

  return (
    <div className="space-y-8">
      <CropperDialog />
      {cropperImage && (
        <>
          <Image
            src={URL.createObjectURL(cropperImage)}
            alt={"preview"}
            width={1600}
            height={900}
          />

          <div className={"flex justify-end"}>
            <Button type="button" onClick={handleClick}>
              投稿する
            </Button>
          </div>
        </>
      )}
    </div>
  );
};

Discussion