🔍

Remix で画像トリミングモーダルを実装する🔍

2024/12/23に公開

この記事はファンタラクティブ2024年アドベントカレンダー 12月23日の記事です

🎯 今回はRemixを使用し以下のようにアップロードした画像をトリミングするモーダルを作成します。

使用するバージョン

@remix-run/node@2.15.1
@remix-run/react@2.15.1
@remix-run/serve@2.15.1

radix-ui/react-slider@1.2.2
react-easy-crop@5.2.0

セットアップ

※ 「実装を早く!」という方はこちらのセットアップはskipしてください。

1.Remixのインストール

npx create-remix@latest

2.必要なライブラリのインストール
react-easy-crop@radix-ui/react-slider をインストールします。

npm install @radix-ui/react-slider react-easy-crop

型定義もインストールします。

npm install -D @types/react-easy-crop

ディレクトリ構成

app/  
├── components/  
│   ├── CropModal.tsx  // トリミングモーダルコンポーネント
│   └── Slider.tsx     // スライダーコンポーネント
└── routes/  
    └── _index.tsx     // トリミングモーダルコンポーネント使用箇所

スライダーコンポーネントの作成

スライダーは、トリミング時の拡大率を調整するために使用します。
※ スライダーは以下の赤枠の箇所になります。

import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";

type SliderProps = React.ComponentProps<typeof SliderPrimitive.Root> & {
  className?: string;
};

const Slider = React.forwardRef<
  React.ElementRef<typeof SliderPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ ...props }: SliderProps, ref) => (
  <SliderPrimitive.Root
    ref={ref}
    className="relative flex w-full touch-none select-none items-center"
    {...props}
  >
    <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-gray-300">
      <SliderPrimitive.Range className="absolute h-full bg-gray-600" />
    </SliderPrimitive.Track>
    <SliderPrimitive.Thumb className="block bg-black h-5 w-5 rounded-full border-2 border-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2" />
  </SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;

export { Slider };

※ ref使用しない or 必要無くなったならforwardRefは外して大丈夫です!

トリミングモーダルコンポーネントの作成

次に、画像をトリミングするための CropModal コンポーネントを作成します。

import { useCallback, useEffect, useState } from "react";
import Cropper from "react-easy-crop";
import type { Area, Point } from "react-easy-crop";
import { Slider } from "./Slider";

type CropsModalProps = {
  isOpen: boolean;
  imageSrc: string | null;
  onClose: () => void;
  onConfirm: (croppedBase64: string) => void;
};

export const CropModal = (props: CropsModalProps) => {
  const { isOpen, imageSrc, onClose, onConfirm } = props;
  const [crop, setCrop] = useState<Point>({ x: 0, y: 0 });
  const [zoom, setZoom] = useState(1);
  const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);

  useEffect(() => {
    const preventDefault = (e: TouchEvent) => e.preventDefault();

    if (isOpen) {
      document.body.style.overflow = "hidden";
      document.addEventListener("touchmove", preventDefault, {
        passive: false,
      });
    } else {
      document.body.style.overflow = "";
    }

    return () => {
      document.body.style.overflow = "";
      document.removeEventListener("touchmove", preventDefault);
    };
  }, [isOpen]);

  const onCropComplete = useCallback(
    (_croppedArea: Area, croppedAreaPixels: Area) => {
      setCroppedAreaPixels(croppedAreaPixels);
    },
    []
  );

  const handleSaveImage = useCallback(async () => {
    if (croppedAreaPixels && imageSrc) {
      try {
        // ローカルで画像を読み込むロジック
        const image = await new Promise<HTMLImageElement>((resolve, reject) => {
          const img = new Image();
          img.onload = () => resolve(img);
          img.onerror = (error) => reject(error);
          img.src = imageSrc;
        });

        // トリミング処理
        const canvas = document.createElement("canvas");
        const ctx = canvas.getContext("2d");

        if (!ctx) {
          throw new Error("Canvas context not found");
        }

        // Canvasのサイズをトリミング範囲に設定
        canvas.width = croppedAreaPixels.width;
        canvas.height = croppedAreaPixels.height;

        // Canvasにトリミング範囲を描画
        ctx.drawImage(
          image,
          croppedAreaPixels.x,
          croppedAreaPixels.y,
          croppedAreaPixels.width,
          croppedAreaPixels.height,
          0,
          0,
          croppedAreaPixels.width,
          croppedAreaPixels.height
        );

        // Base64形式で画像を取得
        const croppedBase64 = canvas.toDataURL("image/jpeg");

        // 結果を呼び出し元に返す
        onConfirm(croppedBase64); // トリミング後のBase64画像を返す
        onClose(); // モーダルを閉じる
      } catch (e) {
        console.error("Error during image processing:", e);
      }
    }
  }, [croppedAreaPixels, imageSrc, onConfirm, onClose]);

  if (!isOpen || !imageSrc) return null;

  return (
    <div
      className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50"
      role="button"
      tabIndex={0}
      onClick={(e) => {
        e.stopPropagation();
      }}
      onKeyDown={(e) => {
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
        }
      }}
    >
      <div className="bg-white rounded-lg shadow-lg text-center relative h-[561px] w-[326px] transform translate-y-[-10%]">
        <button
          onClick={onClose}
          className="absolute top-2 right-3 text-gray-500 hover:text-gray-700"
        ></button>
        <p className="font-bold mt-6 text-black">写真をトリミング</p>
        <div className="flex flex-col p-4">
          <div className="w-full h-[417px] overflow-hidden flex justify-center items-center relative mt-6">
            <Cropper
              image={imageSrc}
              crop={crop}
              zoom={zoom}
              aspect={1}
              onCropChange={setCrop}
              onCropComplete={onCropComplete}
              onZoomChange={setZoom}
              cropShape="round" // 円形トリミング
              cropSize={{ width: 240, height: 240 }}
              showGrid={false}
            />
          </div>
          <div className="mt-8 w-[278px]">
            <Slider
              value={[zoom]}
              onValueChange={(value: number[]) => setZoom(value[0])}
              min={1}
              max={3}
              step={0.1}
            />
          </div>
          <div className="w-full mt-[48px]">
            <button
              color="transparent"
              onClick={handleSaveImage}
              className="bg-white text-black mt-4 w-full h-[64px]"
            >
              OK
            </button>
          </div>
        </div>
      </div>
    </div>
  );
};

    <Cropper
      image={imageSrc}
      crop={crop}
      zoom={zoom}
      aspect={3 / 4}
      onCropChange={setCrop}
      onCropComplete={onCropComplete}
      onZoomChange={setZoom}
      cropShape="rect"
      cropSize={{ width: 240, height: 320 }}
      showGrid={false}
    />

コンポーネント呼び出し箇所

作成したモーダルをroutes/_index.tsxに配置しアップロードボタンをクリックすることで表示できるようにします。

import { ChangeEvent, useRef, useState } from "react";
import { CropModal } from "~/components/CropModal";

export default function Index() {
  const [modalOpen, setModalOpen] = useState(false);
  const [modalImage, setModalImage] = useState<string | null>(null);
  const [uploadImage, setUploadImage] = useState<string | null>(null);

  const fileInputRef = useRef<HTMLInputElement>(null);

  const handlePressUpload = () => {
    fileInputRef.current?.click();
  };

  const handleImageConfirm = async (croppedBase64: string) => {
    setUploadImage(croppedBase64);
    setModalImage(null);
  };

  const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
    event.stopPropagation();
    const file = event.target.files?.[0];
    if (file) {
      const imageUrl = URL.createObjectURL(file);
      setModalImage(imageUrl); // モーダル専用のstateに設定
      setModalOpen(true);

      event.target.value = "";
    }
  };
  return (
    <div className="flex flex-col h-screen gap-8 items-center justify-center">
      <p>写真をアップロード</p>
      <input
        className="hidden"
        id="file_input"
        type="file"
        accept="image/jpeg,image/png"
        onChange={handleFileChange}
        ref={fileInputRef}
      />
      <button
        className="bg-white text-black rounded-lg p-2"
        onClick={handlePressUpload}
      >
        アップロード
      </button>
      {uploadImage && (
        <div className="w-[400px]">
          <img src={uploadImage} alt="UPLOAD IMG" />
        </div>
      )}

      <CropModal
        isOpen={modalOpen}
        imageSrc={modalImage}
        onClose={() => setModalOpen(false)}
        onConfirm={handleImageConfirm}
      />
    </div>
  );
}

エラー対応

Remixでreact-easy-cropを使用すると以下のようなエラーに出くわすかもしれません。

その場合はvite.config.tsで以下のように追記してください。

export default defineConfig({
  plugins: [
    remix({
      future: {
        v3_fetcherPersist: true,
        v3_relativeSplatPath: true,
        v3_throwAbortReason: true,
        v3_singleFetch: true,
        v3_lazyRouteDiscovery: true,
      },
    }),
    tsconfigPaths(),
  ],
  ssr: {
    noExternal: ["react-easy-crop"], // これを追加
  },
});

ということでなかなか盛り盛りの実装となりましたが以上で完成になります!

次の記事

次回は、デザインマネージャーの永田さんの記事です!どうぞお楽しみに!

ファンタラクティブテックブログ

Discussion