ファイルの選択ボタンを押して、ファイル選択ダイアログから選択した画像または、ローカルにある画像をドラッグ&ドロップしてブラウザにプレビュー表示するUIを作成します。
開発環境の構築
Reactアプリの開発環境の構築についてはReactの開発環境を構築するをご覧ください。
ディレクトリ構成
プロジェクトのディレクトリ構成を下記のようにします。「my-app」はプロジェクト名です。
my-app
└── src
├── components
| ├── Button.js
| ├── LocalImagePreviewer
| ├── LocalImageSelectButton.js
│ └── Margin.js
├── hooks
│ └── useLocalImage.js
├── styles
│ └── FlexAllCenterStyle.js
├── App.css
├── App.js
├── constants.js
├── index.css
├── index.js
└── utils.js
App.css、index.css、index.jsの内容はプロジェクト作成時のままです。
styled-componentsのインストール
ターミナルで以下のコマンドを実行してstyled-componentsをインストールします。
yarn add styled-components
定数を定義する
src配下のconstants.jsに下記の定数を定義します。
constants.js
export const FONT_SIZES = {
xs: ".8vmin",
sm: "1.2vmin",
md: "1.8vmin",
lg: "2vmin"
};
export const COLORS = {
background: "#171734",
white: "#ffffff",
accent: "#ffc107",
bright: "#ffffff",
dark: "#333333",
gray: "#555555",
lightGray: "#bbbbbb",
superLightGray: "#dedede",
red: "#dc3545"
};
export const BUTTON_THEMES = {
default: {
background: COLORS.lightGray,
color: COLORS.dark
},
primary: {
background: COLORS.accent,
color: COLORS.dark
},
danger: {
background: COLORS.red,
color: COLORS.white
}
};
export const BUTTON_SIZES = {
sm: {
fontSize: FONT_SIZES.sm,
padding: ".6vmin 1.6vmin"
},
md: {
fontSize: FONT_SIZES.md,
padding: "1vmin 2vmin"
},
lg: {
fontSize: FONT_SIZES.lg,
padding: "1.4vmin 2.4vmin"
}
};
// 画像サイズ(KB)の上限
export const IMAGE_SIZE_LIMIT = 500;
const LOCAL_IMAGE_PREVIEWER = {
height: "50vmin",
width: "80vmin"
};
共通で使うスタイルをmixinとして定義する
styled-componentsで作成するコンポーネントで共通で使うcssをmixinとして定義します。
FlexAllCenterStyle
src配下のstyles/FlexAllCenterStyle.jsに、要素を上下左右中央に配置するスタイルを定義します。
styles/FlexAllCenterStyle.js
import React from 'react'
import styled from 'styled-components'
const FlexAllCenterStyle = styled.css`
display: flex;
align-items: center;
justify-content: center;
`;
export default FlexAllCenterStyle
コンポーネントを作成する
Reactでは、コンポーネントと呼ばれる部品を組み合わせてUIを構築します。
コンポーネントはpropsと呼ばれるパラメータを受け取り、表示するビューの階層構造を返します。
ということで、src/components配下に以下のコンポーネントを作成します。
尚、コンポーネントを作成する際にコードの見通しを良くするために、コンポーネントはビューを返すのみとして、ロジックはカスタムフックとして切り分けます。そして、コンポーネント使用時に外側(親コンポーネント、今回でいえばAppコンポーネント)でカスタムフックを呼び出し、その返り値をpropsとして渡します。
カスタムフックを外部で呼び出してpropsとして渡す理由は、カスタムフックが返す値を親コンポーネント内で使用できるようにするためです。このようにすることで、親コンポーネント内の他の子コンポーネントもpropsを通じてその値を受け取ることができます。共有できるということ。カスタムフックを子コンポーネント内で呼び出すと、戻り値はその中でしか使用できませんから。
Button
ボタン要素を返すButtonコンポーネントをstyled-componentsで作成します。
components/Button.js
import React from 'react'
import styled from 'styled-components'
import { BUTTON_THEMES, BUTTON_SIZES } from '../constants';
const Button = styled.button`
background-color: ${({ theme }) =>
(theme && theme.background) || BUTTON_THEMES.default.background};
border: none;
border-radius: 0.6vmin;
cursor: ${({ isDisabled }) => (isDisabled ? "cursor" : "pointer")};
color: ${({ theme }) => (theme && theme.color) || BUTTON_THEMES.default.color};
font-size: ${({ size }) =>
(size && size.fontSize) || BUTTON_SIZES.md.fontSize};
outline: none;
opacity: ${({ isDisabled }) => (isDisabled ? 0.5 : 1)};
padding: ${({ size }) => (size && size.fontSize) || BUTTON_SIZES.md.padding};
pointer-events: ${({ isDisabled }) => (isDisabled ? "none" : "auto")};
user-select: none;
`;
export default Button
このコンポーネントはpropsとして4つの値(theme、size、isDisabled、onClick)を受け取ります。
4つの値については次の通りです。
theme
backgroundとcolorのプロパティを持つオブジェクトであり、値はいずれもカラーコードまたはカラー名を文字列で指定します。
<Button
theme={{
background: '#aaaaaa',
color: 'white'
}}
>
受け取った値は、以下のようにしてcssのbackground-colorプロパティとcolorプロパティにセットされ、これによりボタンのテーマが設定されます。
background-color: ${({ theme }) => (theme && theme.background) || BUTTON_THEMES.default.background};
color: ${({ theme }) => (theme && theme.color) || BUTTON_THEMES.default.color};
尚、themeを受け取らなかった場合は、それぞれデフォルトのカラーが設定されます。
size
fontSizeとpaddingのプロパティを持つオブジェクトであり、fontSizeプロパティにはフォントサイズを、paddingプロパティには上下左右のパディングをいずれも文字列で以下のように指定します。
<Button
size={{
fontSize: '1em',
padding: '10px 20px'
}}
>
受け取った値は、以下のようにしてcssのfont-sizeプロパティとpaddingプロパティにセットされ、これによりボタンのテーマが設定されます。
font-size: ${({ size }) => (size && size.fontSize) || BUTTON_SIZES.md.fontSize};
padding: ${({ size }) => (size && size.fontSize) || BUTTON_SIZES.md.padding};
尚、themeを受け取らなかった場合は、それぞれデフォルトのフォントサイズとパディングが設定されます。
isDisabled
ボタンのクリックが有効または無効の状態を示す真偽値であり、falseであればボタンのクリックが有効になり、trueであれば無効になります。
受け取った値は、以下のようにしてcssのcursor、opacity、pointer-eventsプロパティの値が設定される際の条件式で使われます。
cursor: ${({ isDisabled }) => (isDisabled ? "cursor" : "pointer")};
opacity: ${({ isDisabled }) => (isDisabled ? 0.5 : 1)};
pointer-events: ${({ isDisabled }) => (isDisabled ? "none" : "auto")};
isDisabledがtrueであればcssは
cursor: default;
opacity: .5;
pointer-events: none;
となり、ボタンのクリックは無効になります。
反対にfalseであればcssは、
cursor: pointer;
opacity: 1;
pointer-events: auto;
となり、ボタンのクリックが有効になります。
onClick
クリックされたときに実行する関数であり、関数名か無名関数で以下のように指定します。
// 関数名の場合
<Button
onClick={handleClick}
>
// 無名関数の場合
<Button
onClick={() => handleClick()}
>
受け取った値はbutton要素のonclick属性に設定されます。
LocalFileSelectButton
ローカルからファイルを選択するボタンのビューを返すLocalFileSelectButtonコンポーネントを作成します。
components/LocalFileSelectButton.js
import React from 'react'
import styled from 'styled-components'
import Button from './Button';
const StyledLocalFileSelectButton = Button.withComponent("label");
const Input = styled.input`
display: none;
`;
const LocalFileSelectButton = ({
className,
theme,
size,
clearFilePath,
handleChange,
accept,
children
}) => (
<StyledLocalFileSelectButton
htmlFor="file"
className={className}
theme={theme}
size={size}
onClick={clearFilePath}
onChange={handleChange}
>
<Input
type="file"
id="file"
name="file"
className="file"
accept={accept}
/>
{children}
</StyledLocalFileSelectButton>
);
export default LocalFileSelectButton
LocalFileSelectButtonコンポーネントは、StyledLocalFileSelectButtonコンポーネントとInputコンポーネントで構成されています。
これら2つのコンポーネントはいずれもstyled-componentsで作成しています。
また、LocalFileSelectButtonコンポーネントはpropsとしてtheme、size、clearFilePath、loadLocalImage、childrenという名前の値を受け取ります。
これらの値については次の通りです(theme、sizeについては前述のButtonコンポーネントと同じものであるため省略します)。
clearFilePath
clearFilePath関数を指定します。clearFilePath関数については後述します。
loadLocalImage
loadLocalImage関数を指定します。loadLocalImage関数については後述します。
children
予備知識として、childrenは特別なpropsであり、以下のようにしてコンポーネントで囲む形で値を渡します。
<Button>Start</Button>
受け取った値は以下のようにコンポーネントが返すビューの子要素として出力されます。
<button>{children}</button>
LocalFileSelectButtonコンポーネントでは、childrenとして受け取った文字列をそのままボタン名として出力しています。
StyledLocalImageSelectButtonコンポーネントが持つスタイルは、Buttonコンポーネントが持つスタイルと全く同じであるため、
Button.withComponent("label");
として、タグ名のみをlabelに変更しています。
そもそもなぜファイルを選択するボタンはlabel要素とinput要素で構成しているのかというと、オリジナルデザインにカスタマイズするためです。
というのも、type属性にfileを指定したinput要素は、以下のように期待どおりにCSSが適用されない上、ボタンのラベルも変更できません。
そのため、次のようにしてファイルを選択するボタンをオリジナルデザインにカスタマイズしています。
<input type="file">
をdisplay:none
で非表示にする。<input type="file">
をlabel要素で囲い、label要素内にボタン名の文字列を入れる。- label要素のhtmlFor属性にinput要素のid属性と同じ値を設定して紐づけることで、label要素を押したときでもファイル選択ダイアログが開くようになる。
- label要素にボタンのスタイルを割り当てる。
いい?要はlabel要素をボタンに見立てているってこと!信じるか信じないかはあなた次第です👆
Margin
要素間にマージンを加えるためのMarginコンポーネントを作成します。
components/Margin.js
import React from 'react'
import styled from 'styled-components'
const Margin = styled.div`
margin-top: ${({ top }) => (top ? top : `0`)};
margin-right: ${({ right }) => (right ? right : `0`)};
margin-bottom: ${({ bottom }) => (bottom ? bottom : `0`)};
margin-left: ${({ left }) => (left ? left : `0`)};
width: 100%;
`;
export default Margin
このコンポーネントはpropsとして4つの値(top、right、bottom、left)を文字列(例:"14px")で受け取ります。受け取った値はそれぞれのmarginに設定されます。
尚、このコンポーネントを使用する際にpropsを省略することも可能です。そうした場合、省略した方向のマージンは0になります。
使用例
<Margin top="1em" />
LocalImagePreviewer
これまで作成したコンポーネントを組み合わせて、LocalImagePreviewerコンポーネントを作成します。このコンポーネントは今回作成するUIのビュー全体を指します。
components/LocalImagePreviewer.js
import React from 'react'
import styled from 'styled-components'
import Button from './Button';
import Margin from './Margin';
import LocalImageSelectButton from './LocalImageSelectButton';
import FlexAllCenterStyle from '../styles/FlexAllCenterStyle';
import { COLORS, FONT_SIZES, BUTTON_THEMES } from '../constants'
const LocalImage = styled.img`
max-height: 100%;
width: auto;
`;
const DragZone = styled.div`
border-color: ${({ borderColor }) => borderColor || COLORS.lightGray};
border-width: 0.4vmin;
border-style: dashed;
box-sizing: border-box;
${FlexAllCenterStyle};
flex-direction: column;
overflow: hidden;
height: 100%;
width: 100%;
`;
const Message = styled.h3`
color: ${COLORS.dark};
font-size: ${FONT_SIZES.md};
`;
const StyledLocalImagePreviewer = styled.div`
background-color: ${({ background }) => background || COLORS.superLightGray};
border-radius: 1vmin;
box-sizing: border-box;
height: ${({ height }) => height};
padding: 2vmin;
width: ${({ width }) => width};
`;
const LocalImagePreviewer = ({
localImagePreviewer,
height,
width,
background
}) => (
<React.Fragment>
<StyledLocalImagePreviewer
height={height}
width={width}
background={background}
>
<DragZone
onDragOver={localImagePreviewer.handleDragOver}
onDrop={localImagePreviewer.handleDrop}
borderColor={background}
>
<LocalImage src={localImagePreviewer.localImage} />
{!localImagePreviewer.localImage && (
<React.Fragment>
<Message>Drag and drop a file to upload...</Message>
<Margin top="1.4vmin" />
<LocalImageSelectButton
theme={BUTTON_THEMES.primary}
loadLocalImage={localImagePreviewer.loadLocalImage}
clearFilePath={localImagePreviewer.clearFilePath}
>
Open file Selector
</LocalImageSelectButton>
</React.Fragment>
)}
</DragZone>
</StyledLocalImagePreviewer>
<Margin bottom="3vmin" />
<Button
onClick={localImagePreviewer.removeLocalImage}
isDisabled={!localImagePreviewer.localImage}
theme={BUTTON_THEMES.danger}
>
Delete file
</Button>
</React.Fragment>
);
export default LocalImagePreviewer
このコンポーネントはStyledLocalImagePreviewer、DropZone、LocalImage、Message、Margin、LocalImageSelectButton、Buttonコンポーネントで構成されています。
これらのコンポーネントについては次の通りです(Margin、Buttonコンポーネントについては前述の通りであるため省略します)。
StyledLocalImagePreviewer
LocalImagePreviewerコンポーネントが描画するビューとそのスタイルを併せ持つstyled-componentsで作成したコンポーネントであり、propsとしてheight、width、backgroundという名前の値を受け取ります。
受け取った値は、それぞれCSSのbackground-color、height、widthプロパティにセットされます。
DropZone
styled-componentsで作成しており、画像をドロップできる領域を表すビューとそのスタイルを持ちます。 また、propsとしてbackgroundという名前の値を受け取り、受け取った値はCSSのbackground-colorプロパティにセットされます。
この領域にドロップされた画像は、Flexboxによって領域の中央に表示されるようにしています。
尚、表示の際に画像の幅がこの領域を超えた場合は、overflow: hiddenで、はみ出た部分を隠します。
LocalImage
styled-componentsで作成しており、プレビュー表示する画像とそのスタイルを持ちます。
スタイルでは、
max-height: 100%;
width: auto;
として、縦横比を維持したままドロップ領域に上下ぴったり表示されるようにしています。
Message
「Drag and drop a file to upload...」の部分の表示とそのスタイルを持ちます。
LocalImageSelectButton
Buttonコンポーネントはプレビュー表示した画像を削除するボタンを表示します。 このボタンはドロップ領域に画像が表示されているときのみ、クリックを有効にします。
ドロップ領域には、ファイル選択ダイアログから画像を選択したときのみ画像を表示し、選択していないときはメッセージとファイル選択ボタンを表示します。
カスタムフックを作成する
コンポーネントのロジック部分を、他のコンポーネントでも使い回せるようにするため、コンポーネントのロジック部分を抽出した関数であるカスタムフックを作成します。
useLocalImagePreviewer
LocalImagePreviewerコンポーネントのロジックであるuseLocalImagePreviewerフックを作成します。
useLocalImagePreviewer.js
const useLocalImagePreviewer = (sizeLimit) => {
const [localImage, setLocalImage] = useState(null);
const getLocalFileDataURL = async (fileData) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(fileData);
reader.addEventListener("load", (e) => {
console.log(e);
const loadedFile = e.target.result;
resolve(loadedFile);
});
});
};
const loadLocalImage = useCallback(async (e) => {
const fileData = e.target.files[0];
if (!fileData) return; //エクスプローラでキャンセルしたときにエラーが発生しないように
if (!fileData.type.match("image.*")) {
alert("Please select image");
return;
}
if (sizeLimit) {
if (fileData.size > sizeLimit * 1000) {
alert(`File size should be ${sizeLimit}KB or less.`);
return;
}
}
const loadImageURL = await getLocalFileDataURL(fileData);
setLocalImage(loadImageURL);
}, []);
const clearFilePath = useCallback((e) => {
e.target.value = null;
}, []);
const removeLocalImage = useCallback(() => setLocalImage(null), []);
const handleDrop = useCallback(async (e) => {
e.stopPropagation();
e.preventDefault();
const files = e.dataTransfer.files; // FileList
const fileData = files[0]; // File
if (!fileData.type.match("image.*")) {
alert("Please select image");
return;
}
if (sizeLimit) {
if (fileData.size > sizeLimit * 1000) {
alert(`File size should be ${sizeLimit}KB or less.`);
return;
}
}
const loadImageURL = await getLocalFileDataURL(fileData);
setLocalImage(loadImageURL);
});
const handleDragOver = useCallback((e) => {
e.stopPropagation();
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}, []);
return {
localImage,
loadLocalImage,
clearFilePath,
removeLocalImage,
handleDragOver,
handleDrop
};
};
このフックはLocalImagePreviewerコンポーネントで使われる値や関数をプロパティに持つオブジェクトを返します。
このオブジェクトが持つプロパティについては次の通りです。
handleChange
handleChange関数は、ファイルの選択ボタン<input type="file">
を押して開いたファイル選択ダイアログ内のファイルを選択したときに実行される関数です。
LocalImageSelectButtonコンポーネントの使用時に、この関数をpropsとしてonChangeという名前で渡します。
これにより、ファイルの選択ボタンを押して開いたファイル選択ダイアログ内のファイルを選択したときにchangeイベントが発生し、そのタイミングでhandleChange関数が実行されます。
handleChange関数が実行されると、次のことが行われます。
- ローカルから選択したファイルの情報を取得する
- ファイルのサイズを制限する
- 画像以外のファイルは読み込まないようにする
- ローカルから選択したファイル(画像)のURLを取得する
1. ローカルから選択したファイルの情報を取得する
ローカルからファイルを選択したときに<input type="file">
のchangeイベントが発生するため、そのタイミングで選択したファイルの情報を取得します。
ファイルの情報を取得するには、まずファイルの情報一覧をイベントターゲットのfilesプロパティから参照します。そして、そこからファイルの情報を取り出しますが、ファイルの情報一覧はオブジェクトであるため、files[0]
のようにして番地を指定するか、for文などによるループ処理を行い1つ1つ取り出します。
番地を指定した例
const fileData = e.target.files[0];
取り出したファイル情報は、ファイル名name
、ファイルサイズsize
、ファイルのMIMEタイプtype
、最終更新日lastModifiedDate
のプロパティを持つオブジェクトです。
2. ファイルのサイズを制限する
ファイルを読み込む際に、ファイルのサイズを制限します。
その方法として、ファイルのサイズが制限サイズを超えた場合にアラートを出し、アラートを閉じた時点で処理を終了します。
今回作成するUIでは、500KBを超えるサイズのファイルは読み込まないようにします。
if (fileData.size > sizeLimit * 1000) {
alert(`File size should be ${sizeLimit}KB or less.`);
return;
}
3. 画像以外のファイルは読み込まないようにする
ファイルを読み込む際にそのファイルが画像でなければその時点で処理を終了します。
その方法として、ファイルのMIMEタイプの文字列に「image.*
」が含まれていなければ、選択したファイルは画像ではないということなので、その時点で処理を終了します。
if (!fileData.type.match("image.*")) {
alert("Please select image");
return;
}
4. ローカルから選択した画像のURLを取得する
getLocalFileDataURL関数を実行して、ローカルから選択した画像のURLを取得します。
getLocalFileDataURL関数はPromiseを返し、そのPromiseはローカルから選択した画像のDataURLを返します。
getLocalFileDataURL関数を呼び出す際に、関数名の前にawaitを指定することで、getLocalFileDataURL関数が返すPromiseが解決するまで、handleChange関数の処理を中断します。
await getLocalFileDataURL(fileData);
awaitの指定は、handleChange関数が非同期関数(async function)であるからこそできることです。
const handleChange = useCallback(async (e) => {...}
getLocalFileDataURL関数が返すPromiseのコールバック関数では、FileReader APIを使って次のようにしてローカルから選択した画像のURLを返しています。
まず、FileReaderクラスからインスタンスを作ります。
const reader = new FileReader();
次に、FileReaderによるファイルの読み込みが完了すると、FileReaderのloadイベントが発生するので、このタイミングで読み込んだ画像のURLを返すようにします。
reader.addEventListener("load", (e) => {
const loadedFile = e.target.result;
resolve(loadedFile);
});
そして、readerオブジェクトのreadAsDataURLメソッドを実行して、画像をDataURLとして読み込みを開始します。
reader.readAsDataURL(fileData);
getLocalFileDataURL関数についてはここまでです。
handleChange関数に戻ります。最後に、getLocalFileDataURL関数から返ってきたURLでlocalImageを上書きします。
const loadImageURL = await getLocalFileDataURL(fileData);
setLocalImage(loadImageURL);
clearFilePath
ローカルファイルを選択した後に、もう一度同じファイルを選択すると、ファイル選択ボタンのchangeイベントが発生しないため、handleChange関数が実行されず、ファイルを読み込むことができません。
これは、ブラウザが直前に読み込んだファイル名を覚えており、続けて同じファイルを選択すると、changeイベントを発生させないようにしているためです。
この対処法として、clearFilePath関数を実行して直前に読み込んだファイル名を削除します。
e.target.value = null;
ですが前述の通り、changeイベントは発生しないため、このタイミングclearFilePath関数を実行することはできません。
そこで、changeイベントより前に発生するclickイベントを使います。clickイベントが発生したタイミングでclearFilePath関数を実行すれば、changeイベントが発生して同じファイルを読み込むことが可能になります。
<Button onClick={clearFilePath}></Button>
Appコンポーネントを作成する
アプリ全体のビューを返すAppコンポーネントを作成します。
App.js
import React from 'react'
import './App.css'
import styled from 'styled-components'
import LocalImagePreviewer from './components/LocalImagePreviewer'
import useLocalImagePreviewer from './hooks/useLocalImagePreviewer'
import { LOCAL_IMAGE_PREVIEWER, IMAGE_SIZE_LIMIT, COLORS } from './constants'
import { FlexAllCenterStyle } from './styles/FlexAllCenterStyle';
const StyledApp = styled.div`
background-color: ${COLORS.background};
${FlexAllCenterStyle};
height: 100vh;
width: 100%;
`;
const Container = styled.div`
${FlexAllCenterStyle};
flex-direction: column;
padding: 4vmin;
width: 100%;
`;
const App = () => {
const localImagePreviewer = useLocalImagePreviewer(IMAGE_SIZE_LIMIT);
return (
<StyledApp>
<Container>
<LocalImagePreviewer
localImagePreviewer={localImagePreviewer}
height={LOCAL_IMAGE_PREVIEWER.height}
width={LOCAL_IMAGE_PREVIEWER.width}
/>
</Container>
</StyledApp>
);
};
export default App
Appコンポーネントが返すビューは、StyledApp、Container、LocalImagePreviewerコンポーネントで構成されています。
このうち、StyledApp、Containerコンポーネントはstyled-componentsで作成しています。
Appコンポーネント内でuseLocalImagePreviewerフックを呼び出し、戻り値のlocalImagePreviewerオブジェクトをLocalImagePreviewerコンポーネントにpropsとして渡しています。更に、heightとwidthに予め定義した定数を渡してUIのサイズを設定しています。
レンダリング
最後に、Appコンポーネントをレンダリングしてページ上にUIを表示します。
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
Demo
下記で、実際にローカル画像を選択すると、画像がサムネイル表示されることが確認できます。