React + TypeScript: 動的に読み込んだ画像ファイルのイメージを縦横比は変えずに一定サイズに収める

動的に読み込んだ画像ファイルのイメージを、決まった矩形領域に収めます。最終的にはアスペクト比は保ったまま、つまり領域に対して縦横の比率の大きい方をぴったりの長さにするということです。そして、領域内には縦横中央揃えに配置します。でき上がりのCodeSandbox作例が、つぎのサンプル001です。

#### サンプル001■React + TypeScript: Resizing dynamically loaded image with aspect ratio kept
https://codesandbox.io/s/react-typescript-resizing-dynamically-loaded-image-with-aspect-ratio-kept-wntqgy

この記事は全3回のチュートリアルシリーズの第2回です。3回とおしてつぎのサンプル002ような画像ファイル操作のインタフェース例をつくります。`<input type="file">`要素を共通に扱うものの、それぞれテーマは別です。興味に応じて、解説を個別に読んでいただいても構いません。

#### サンプル002■React + TypeScript: Change style of <input type="file"> with MUI
https://codesandbox.io/s/react-typescript-change-style-of-input-type-file-with-mui-ufvjqt

**チュートリアルシリーズ**
1. 「[React + TypeScript: \<input type="file"\>要素で選択した画像ファイルのイメージをページに表示する](https://qiita.com/FumioNonaka/items/4f0fc65975eed5b89c0c)」
2. 「React + TypeScript: 動的に読み込んだ画像ファイルのイメージを縦横比は変えずに一定サイズに収める」(本稿)
3. 「[React + TypeScript: \<input type="file"\>要素の見映えのカスタマイズとファイルのドロップによる選択 ー MUIを用いて](https://qiita.com/FumioNonaka/items/6b28f809648c5d47d32d)」

本稿のコード例解説は、前回の「React + TypeScript: \<input type="file"\>要素で選択した画像ファイルのイメージをページに表示する」でつくった[CodeSandbox作例](https://codesandbox.io/s/react-typescript-displaying-image-selected-with-input-type-file-v1j229)に手を加えていくかたちで進めます。動的に読み込んだ画像ファイルのイメージ表示については、この記事をお読みください。



# 画像イメージを領域のサイズに変形して収める
一旦アスペクト比は置きましょう。画像イメージを決められた矩形領域のサイズぴったりに変形するということです。縦横比が違えば、イメージは歪みます。

イメージを差し込む要素(`<div>`)には、つぎのスタイルモジュール`src/styles.ts`でサイズと外枠を加えます。

```react:src/styles.ts
export const imageDisplaySize = { width: 200, height: 200 };
export const styles = {
	imageContainer: {
		width: imageDisplaySize.width,
		height: imageDisplaySize.height,
		marginTop: 10,
		border: '1px solid lightgray'
	}
};
```

画像ファイルの操作と表示のモジュール`src/FileInput.tsx`で、イメージ差し込み先要素(`<div>`)には`style`プロパティでスタイル(`styles`)を与えてください。

```react:src/FileInput.tsx
import { styles } from './styles';

export const FileInput: FC = () => {

	return (
		<div>

			{/* <div ref={imageContainerRef} /> */}
			<div ref={imageContainerRef} style={{...styles.imageContainer}} />
		</div>
	);
};
```

画像イメージを読み込むのは、フックのモジュール`src/hooks.ts`です。イメージサイズを操作する関数`manipulateImageSize`は、新たに加えることにしました。関数が操作している変数(`fileImage`)の中身は、`Image()`コンストラクタでつくった`HTMLImageElement`インスタンスです。`src`プロパティにURLの文字列を与えれば、画像イメージが設定されます。プロパティ`width`と`height`で、差し込み先要素と同じサイズに変形しました。

```react:src/hooks.ts
import { imageDisplaySize } from './styles';

export const useHooks = () => {

	const manipulateImageSize = (url: string) => {
		fileImage.src = url;
		fileImage.width = imageDisplaySize.width;
		fileImage.height = imageDisplaySize.height;
	};

	const handleFiles: ChangeEventHandler<HTMLInputElement> = (event) => {

		// fileImage.src = objectURL;
		manipulateImageSize(objectURL);

	};

};
```


# イメージのアスペクト比を保つ
いよいよイメージのアスペクト比を保つ考え方です。動的に読み込んだ画像ファイルの実際のサイズを知らなければなりません。そのためのプロパティが、[`HTMLImageElement.naturalWidth`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/naturalWidth)と[`HTMLImageElement.naturalHeight`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/naturalHeight)です(読み込み専用)。伸縮した大きさでなく、本来の解像度におけるもともとのピクセル数値を返します。

ところが、`HTMLImageElement`インスタンスの`src`プロパティにURLを与えてすぐに調べると、`naturalWidth`も`naturalHeight`も値は0です。画像イメージの読み込みを待たなければなりません。`onload`イベントハンドラが呼び出されれば、数値が正しく得られます。

アスペクト比を保つには、縦横に同じ比率を掛ければよいことです。つまり、領域に対するイメージの縦と横それぞれ同士の比率を求め、大きい方は領域と同じ値にします。他方には、同じ伸縮率を掛け合わせれば、アスペクト比は変わらず、領域にちょうど収まるのです。

```react:src/hooks.ts
export const useHooks = () => {

	const manipulateImageSize = (url: string) => {
		fileImage.src = url;
		/* fileImage.width = styles.imageDisplaySize.width;
		fileImage.height = styles.imageDisplaySize.height; */
		fileImage.onload = () => {
			const width = fileImage.naturalWidth;
			const height = fileImage.naturalHeight;
			console.log('load size:', width, height);  // 確認用
			const ratioWidth = width / imageDisplaySize.width;
			const ratioHeight = height / imageDisplaySize.height;
			if (ratioWidth > ratioHeight) {
				// fileImage.width = width / ratioWidth;  // と同じ
				fileImage.width = imageDisplaySize.width;
				fileImage.height = height / ratioWidth;
			} else {
				fileImage.width = width / ratioHeight;
				// fileImage.height = height / ratioHeight;  // と同じ
				fileImage.height = imageDisplaySize.height;
			}
		};
	};

};
```

領域の中における画像イメージの中央揃えは、フレックスを使えば簡単です。

```react:src/styles.ts
export const styles = {
	imageContainer: {
		display: 'flex',
		justifyContent: 'center',
		alignItems: 'center',

	}
};
```

動的に読み込んだ画像ファイルのイメージを、縦横比は保ったまま決まった矩形領域にぴったり収めることができました。領域内のイメージの配置は、縦横とも中央揃えです。3つのモジュールそれぞれの記述全体をつぎのコード001にまとめました。作例の各モジュールの詳しい中身や、アプリケーションの動きについては、冒頭に掲げたサンプル001でお確かめください。

#### コード001■動的に読み込んだ画像イメージの縦横比は変えずに一定サイズに収める
```react:src/FileInput.tsx
import { FC } from 'react';
import { useHooks } from './hooks';
import { styles } from './styles';

export const FileInput: FC = () => {
	const { handleFiles, imageContainerRef } = useHooks();
	return (
		<div>
			<input type="file" accept="image/*" onChange={handleFiles} />
			<div ref={imageContainerRef} style={{ ...styles.imageContainer }} />
		</div>
	);
};
```

```react:src/hooks.ts
import { ChangeEventHandler, useRef, useState } from 'react';
import { imageDisplaySize } from './styles';

const fileImage = new Image();
export const useHooks = () => {
	const imageContainerRef = useRef<HTMLDivElement>(null);
	const [objectURL, setObjectURL] = useState('');
	const manipulateImageSize = (url: string) => {
		fileImage.src = url;
		fileImage.onload = () => {
			const width = fileImage.naturalWidth;
			const height = fileImage.naturalHeight;
			const ratioWidth = width / imageDisplaySize.width;
			const ratioHeight = height / imageDisplaySize.height;
			if (ratioWidth > ratioHeight) {
				fileImage.width = imageDisplaySize.width;
				fileImage.height = height / ratioWidth;
			} else {
				fileImage.width = width / ratioHeight;
				fileImage.height = imageDisplaySize.height;
			}
		};
	};
	const resetSelection = () => {
		fileImage.src = '';
		const imageContainer = imageContainerRef.current;
		if (imageContainer && fileImage.parentNode === imageContainer) {
			imageContainer.removeChild(fileImage);
		}
		if (objectURL) {
			window.URL.revokeObjectURL(objectURL);
			setObjectURL('');
		}
	};
	const handleFiles: ChangeEventHandler<HTMLInputElement> = (event) => {
		const files = event.currentTarget.files;
		resetSelection();
		if (!files || files?.length === 0) return;
		const file = files[0];
		if (!file.type.includes('image/')) {
			event.currentTarget.value = '';
			return;
		}
		const imageContainer = imageContainerRef.current;
		if (!imageContainer) return;
		const objectURL = window.URL.createObjectURL(file);
		manipulateImageSize(objectURL);
		imageContainer.appendChild(fileImage);
		setObjectURL(objectURL);
	};
	return { handleFiles, imageContainerRef };
};
```

```react:src/styles.ts
export const imageDisplaySize = { width: 200, height: 200 };
export const styles = {
	imageContainer: {
		display: 'flex',
		justifyContent: 'center',
		alignItems: 'center',
		width: imageDisplaySize.width,
		height: imageDisplaySize.height,
		marginTop: 10,
		border: '1px solid lightgray'
	}
};
```