0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【初投稿: React + Typescript】複数枚ステージングさせた画像をトリミングしてみよう Part 1

Last updated at Posted at 2024-06-02

はじめに

投稿者は初心者です。あたたかい目で見守ってくださいますと幸いです。

はじめまして、専門学校HAL名古屋 高度情報処理学科3年生のHinataと申します。
本記事が私の初投稿となります。せっかく学んだことを忘れてしまっては勿体ない極まりないのでアウトプットの場を設けました。よろしくお願いいたします。

とはいっても今回投稿する内容は最近新しく学んだことではなく、一年ほど前に学んだ内容となります。
以前メルカリなどに代表される、「ユーザーが画像を複数枚ステージングさせ、選択した任意の画像をトリミングしたり削除したりできる機能」を開発する機会がありました。
当時はドがつくほどReact初心者だったので、TypescriptではなくJavascriptを用いた、見るに堪えないスパゲティコードとなってしまいました。。。(一応完成はした)

個人的にかなり画期的な機能でしたので、ぜひとも一度の学習で終わらせず、あときれいに作り直してみたいと思ったので筆をしたためさせていただきます。

開発環境整備

以前のスパゲティコードがReactだったので、今回は馴染みのNext.jsではなく、React + Typescriptを用いた開発です。さあどのように以前の作品を昇華させることができるでしょうか?
また今回はビルドツールにVite、トリミングのライブラリにreact-easy-crop、UIライブラリにMaterial UIを使用します。

私はこのプロジェクトにeslintやprettier, huskyの設定を行っていますが、今回必須ではありません。

react-trimming-exampleというフォルダを作成しました。この階層にプロジェクトを作成していきます。

\react-trimming-example> npm create vite ./

Reactを選択し、Typescript + SWCを選択、npm i で必要なパッケージをインストールします。

>   React
>   TypeScript + SWC
>   npm i   

react-trimming-example内にプロジェクトの雛型が完成したら、さっそく起動してみましょう。

npm run dev

localhost:5173にアクセスしてプロジェクトの起動を確認します。

スクリーンショット (23).png

src直下のcssファイル, assetesフォルダ、App.tsx, main.tsxの不要なインポートを削除して一度プロジェクトを真っ白にします。

App.tsx
function App() {
	return <></>;
}

export default App;
main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';

ReactDOM.createRoot(document.getElementById('root')!).render(
	<React.StrictMode>
		<App />
	</React.StrictMode>
);

また今回は一つのページで完結するアプリケーションなので、別段ルーティングのためのライブラリは導入しない予定です。

MUIとreact-easy-cropの導入

今回はMUIも使っていきます。
必要なパッケージを一気にインポートしましょう。

npm i @emotion/react @emotion/styled @mui/icons-material @mui/material react-easy-crop

アイコンは使うか分からないのですが、とりあえず入れてます。

ところでApp.tsxのreturnのところに適当な文章を挿入すると...。

App.tsx
return <>aiueo</>;

スクリーンショット 2024-06-02 023045.png

妙な隙間が...。CSSの初期化がまだみたいですので、main.tsxにMUIのCSSBaselineを追加しておきます。

main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import { CssBaseline } from '@mui/material';

ReactDOM.createRoot(document.getElementById('root')!).render(
	<React.StrictMode>
    <CssBaseline />
		<App />
	</React.StrictMode>
);

スクリーンショット 2024-06-02 023743.png

無事に初期化されました!これで開発環境整備は終了です。

レスポンシブ設計のためのhooksを作成

去年の混沌としたコードを整形するため、今回は色々と細分化していきます。
src直下にhooks階層を作成し、その中にuseBreakPoint.tsxを作成、それを同階層内のindex.tsでexportします。

hooks/useBreakpoint.tsx
import { useMediaQuery, useTheme } from '@mui/material';

export const useBreakPoint = (): string => {
	const theme = useTheme();

	const isXs = useMediaQuery(theme.breakpoints.only('xs'));
	const isSm = useMediaQuery(theme.breakpoints.only('sm'));
	const isMd = useMediaQuery(theme.breakpoints.only('md'));
	const isLg = useMediaQuery(theme.breakpoints.only('lg'));

	const breakpoint = isXs
		? 'xs'
		: isSm
			? 'sm'
			: isMd
				? 'md'
				: isLg
					? 'lg'
					: 'xl';

	return breakpoint;
};
hooks/index.ts
export * from "./useBreakpoint"

useBreakpoint hooksから返される文字列によってCSSを変更することでレスポンシブな設計を目指します。ちなみにYamada UIのuseBreakpointに倣いました。

またindex.tsにexportをまとめることで、importするときにすっきりとします。
(tsconfigのエイリアス設定でもいけます)

画像をステージングするコンポーネントの作成

と、その前に、App.tsxに以下のBoxを追加します。

App.tsx
import { Box } from "@mui/material";

function App() {
	return (
		<>
		<Box
            sx={{
              display: "flex",
              justifyContent: "center",
              alignItems: "center",
              flexDirection: "column",
              gap: "100px",
              width: "1000px",
              maxWidth: "100vw",
              padding: "50px 0",
              margin: "0 auto",
            }}
          >
          </Box>
		</>
	);
}

export default App;

これは一応レイアウトのようなつもりです。
このBoxの中に画像をステージングするコンポーネント、送信するコンポーネントなどを作成します。

src直下に新たにcomponents階層を作成し、同階層内にUploadImages.tsxを作成します。

components/UploadImages.tsx
import { Box } from '@mui/material';
import { useBreakPoint } from '../hooks';

export const UploadImages = () => {
	const breakpoint = useBreakPoint();

	return (
		<Box
			sx={{
				display: 'flex',
				justifyContent: 'flex-start',
				alignItems: 'center',
				flexWrap: 'wrap',
				aspectRatio: ['xs'].includes(breakpoint) ? '2/1' : '4/1',
				overflowX: 'hidden',
				width: '100%',
				padding: '10px 10px 0 0',
				border: 'dashed 2px #000',
			}}
		></Box>
	);
};

先ほど作成したhooksを使ってレイアウトを調節しています。
この中に画像が並びます。

またhooks階層と同じようにindex.tsを作成してexportします。

components/index.ts
export * from './UploadImages';

これをApp.tsxのBox内部に設置してみましょう。

スクリーンショット 2024-06-02 033207.png

また画面サイズが小さくなると、比率が変わっているのがお分かりいただけると思います。

スクリーンショット 2024-06-02 033220.png

グローバルなstateを管理するProviderの作成

ReactのContextを使用して画像のデータ配列などを管理するためのProviderを作成します。
src直下にprovider階層を作成し、Context.tsxを作成、main.tsxにてContextProviderでAppを囲います。

provider/Context.tsx
import { ReactNode, createContext } from 'react';

export const Context = createContext<null>(null);

export const ContextProvider = ({ children }: { children: ReactNode }) => {
	const contextValue = null;

	return <Context.Provider value={contextValue}>{children}</Context.Provider>;
};
main.tsx
<React.StrictMode>
    <ContextProvider>
        <CssBaseline />
        <App />
    </ContextProvider>
</React.StrictMode>

ひとまずproviderはnullを返しておきます。これでstateをグローバルに管理する準備が整いました。なお今回Providerはひとつだけなので、index.tsはこの階層には用意しません。

画像をステージングするロジックの開発

ここからは記述量が一気に増えますので、まとめてコードを投げさせていただきます。

provider/Context.tsxを以下のように書き換えます。

provider/Context.tsx
import { ReactNode, createContext, useState } from 'react';
import { ProviderProps } from '../types';

export const Context = createContext<ProviderProps | null>(null);

export const ContextProvider = ({ children }: { children: ReactNode }) => {
	const [isDragging, setIsDragging] = useState<boolean>(false); // ステージングエリアに画像をドラッグしているか
	const [uploadImages, setUploadImages] = useState<string[]>([]); // トリミング画像のプレビュー用配列
	const [originalImages, setOriginalImages] = useState<string[]>([]); // トリミング前のオリジナル画像配列
	const [crops, setCrops] = useState<{x: number, y: number}[]>([]); // 画像のトリミング位置の配列
	const [zooms, setZooms] = useState<number[]>([]); // 画像の拡大率の配列

	const contextValue = {
		isDragging,
		setIsDragging,
		uploadImages,
		setUploadImages,
		originalImages,
		setOriginalImages,
		crops,
		setCrops,
		zooms,
		setZooms,
	};

	return <Context.Provider value={contextValue}>{children}</Context.Provider>;
};

react-easy-cropは画像のトリミング開始座標を{x: number, y: number}というオブジェクトに保存します(crops)。また画像の拡大率に関しても数値で保存されます(zooms)
オリジナル画像をトリミング結果とは別に持っておく必要性については後程ご紹介させていただきます。

新たにsrc/typesを作成し、その中にProviderProps.d.tsを作成します。

types/ProviderProps.d.ts
export interface ProviderProps {
	isDragging: boolean;
	setIsDragging: (isDragging: boolean) => void;
	uploadImages: string[];
	setUploadImages: (
		uploadImages: string[] | ((prev: string[]) => string[])
	) => void;
	originalImages: string[];
	setOriginalImages: (
		originalImages: string[] | ((prev: string[]) => string[])
	) => void;
	crops: {x: number, y: number}[];
	setCrops: (
        crops: {x: number, y: number}[] | ((prev: {x: number, y: number}[]) => {x: number, y: number}[])
    ) => void;
	zooms: number[];
	setZooms: (
        zooms: number[] | ((prev: number[]) => number[])
    ) => void;
}

Providerコンポーネントの返す型を定義しています。
そして同階層内にindex.tsを用意し、同じようにexportしておきます。

types/index.ts
export * from './ProviderProps';

これで画像の情報を共通化する準備が整いました。
つづいて画像をステージングするロジックをまとめた新しいhooksを作成します。

hooks/useUploadImages.tsx
import { useContext } from 'react';
import { useUploadImagesProps } from '../types';
import { Context } from '../provider/Context';

export const useUploadImages = (): useUploadImagesProps => {
	const context = useContext(Context);
	if (!context) {
		throw new Error('Context is not provided');
	}

	const {
		isDragging,
		setIsDragging,
		uploadImages,
		setUploadImages,
		originalImages,
		setOriginalImages,
		crops,
		setCrops,
		zooms,
		setZooms,
	} = context;

    // 画像をクリックで選択した場合の関数
	const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
		if (uploadImages.length >= 8) return;
		const file = event.target.files?.[0];
		if (file) {
			const fileUrl = URL.createObjectURL(file);
			setUploadImages([...uploadImages, fileUrl]);
			setOriginalImages([...originalImages, fileUrl]);
			event.target.value = '';
		}
	};

    // 画像をドラッグアンドドロップした場合の関数
	const handleFileDrop = (event: React.DragEvent<HTMLDivElement>) => {
		event.preventDefault();
		if (uploadImages.length >= 8) {
            setIsDragging(false);
            return;
        }
		const file = event.dataTransfer.files[0];
		const allowedFormats = [
			'image/png',
			'image/jpeg',
			'image/jpg',
			'image/webp',
		];
		if (file && allowedFormats.includes(file.type)) {
			const fileUrl = URL.createObjectURL(file);
			setUploadImages([...uploadImages, fileUrl]);
			setOriginalImages([...uploadImages, fileUrl]);
			setIsDragging(false);
		} else {
			console.log('許可されていない形式');
			setIsDragging(false);
		}
	};

	return {
		isDragging,
		setIsDragging,
		uploadImages,
		setUploadImages,
		originalImages,
		setOriginalImages,
		crops,
		setCrops,
		zooms,
		setZooms,
		handleFileSelect,
		handleFileDrop,
	};
};

今回は画像を8枚までステージングさせるようにし、許可する形式はpng, jpeg, jpg, webpとします。

つづいて画像をステージングするUploadImages.tsxを以下のように変更します。

components/UploadImages.tsx
import { Box } from '@mui/material';
import { useBreakPoint } from '../hooks';
import { useRef } from 'react';
import { useUploadImages } from '../hooks/useUploadImages';
import { AddPhotoAlternateOutlined } from '@mui/icons-material';

export const UploadImages = () => {
	const breakpoint = useBreakPoint();
	const { isDragging, setIsDragging, handleFileSelect, handleFileDrop } =
		useUploadImages();
	const fileInputRef = useRef<HTMLInputElement>(null);

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

	const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
		event.preventDefault();
		setIsDragging(true);
	};

	const handleDragLeave = () => {
		setIsDragging(false);
	};

	return (
		<>
			<Box
				sx={{
					display: 'flex',
					justifyContent: 'flex-start',
					alignItems: 'center',
					flexWrap: 'wrap',
					aspectRatio: ['xs'].includes(breakpoint) ? '2/1' : '4/1',
					overflowX: 'hidden',
					width: '100%',
					padding: '10px 10px 0 0',
					border: 'dashed 2px #000',
				}}
			>
				<Box
					onDragOver={handleDragOver}
					onDragLeave={handleDragLeave}
					onDrop={handleFileDrop}
					onClick={handleUploadClick}
					sx={{
						display: 'flex',
						flexDirection: 'column',
						justifyContent: 'center',
						alignItems: 'center',
						gap: '5px',
						width: 'calc(100% - 10px)',
						height: 'calc(100% - 10px)',
						margin: '0 0 10px 10px',
						cursor: 'pointer',
						overflow: 'hidden',
						wordBreak: 'break-all',
						color: '#000',
						backgroundColor: isDragging ? '#ddd' : 'transparent',
						transition: 'background-color 0.2s',
						'&:hover': {
							backgroundColor: '#ddd',
						},
					}}
				>
					<AddPhotoAlternateOutlined />
					<div style={{ textAlign: 'center' }}>
						{isDragging
							? '商品画像をここにドロップ'
							: 'クリックまたはドラッグで商品画像をアップロード'}
					</div>
				</Box>
			</Box>
			<input
				type="file"
				accept="image/png,
					image/jpg,
					image/jpeg,
					image/webp"
				ref={fileInputRef}
				style={{
					display: 'none',
				}}
				onChange={handleFileSelect}
			/>
		</>
	);
};

サイトを確認すると...

スクリーンショット (25).png

なんかいい感じのデザインだ!!

スクリーンショット 2024-06-02 053856.png

クリックによる追加のほか、ドラッグアンドドロップにも対応!さらに案内表示も変わる親切に我ながら脱帽してしまいます...。

任意の場所でuploadImages変数の内容を確認してみると...

スクリーンショット 2024-06-02 054228.png

どうやらしっかりと保存できているみたいです!
ちなみにこれらはJavascriptのcreateObjectURLメソッドを使ってブラウザが解釈可能なURLの形式でデータを格納しています。なので型はstringの配列となります。

おわりに

このままいくと記事の内容自体がスパゲティコード化しそうなので、いったんここで区切りとさせていただきます。
次回はステージングした画像の表示や、いよいよトリミング周りのご紹介などができればと思います!ではでは!

今回使用したリポジトリはコチラ!

次回

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?