🔍
Remix で画像トリミングモーダルを実装する🔍
この記事はファンタラクティブ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"], // これを追加
},
});
ということでなかなか盛り盛りの実装となりましたが以上で完成になります!
次の記事
次回は、デザインマネージャーの永田さんの記事です!どうぞお楽しみに!
ユーザーファーストなサービスを伴に考えながらつくる、デザインとエンジニアリングの会社です。エンジニア積極採用中です!hrmos.co/pages/funteractive/jobs
Discussion