Created
November 4, 2020 13:51
-
-
Save takumifukasawa/b2a6ae3b1516fa831e7df4e247693d84 to your computer and use it in GitHub Desktop.
React: custom hooks of managing file input with validation and generate thumbnails
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { includes } from "lodash"; | |
import { ChangeEvent, useRef, useState, RefObject } from "react"; | |
import getExt from "~/libs/file/getExt"; | |
import getImageSize from "~/libs/image/getImageSize"; | |
import resizeBase64Image from "~/libs/image/resizeBase64Image"; | |
// NOTE: MB 以外の基準も対応できるようにする? | |
const calcFileMegaByteSize = (file: File): number => { | |
const megaByte = 1024 * 1024; | |
return Math.ceil(file.size / megaByte); | |
}; | |
export const FileInputErrors = <const>{ | |
NONE: "NONE", | |
LARGE_FILE_SIZE: "LARGE_FILE_SIZE", | |
INVALID_FILE: "INVALID_FILE", | |
NOT_ACCEPTED_EXTENSION: "NOT_ACCEPTED_EXTENSION", | |
FAILED_TO_READ_FILE: "FAILED_TO_READ_FILE", | |
}; | |
export type FileInputErrors = typeof FileInputErrors[keyof typeof FileInputErrors]; | |
type ImageSize = { | |
width?: number; | |
height?: number; | |
}; | |
export type LocalFile = { | |
name: string | null; | |
size: number | null; | |
rawBase64Image: string | null; | |
thumbnailBase64Image: string | null; | |
imageSize: { width: number; height: number }; | |
}; | |
export const FileInputStates = <const>{ | |
STAND_BY: "STAND_BY", | |
LOADING: "LOADING", | |
}; | |
export type FileInputStates = typeof FileInputStates[keyof typeof FileInputStates]; | |
type Props = { | |
maxFileSize?: number; | |
onSuccess?: () => void; | |
thumbnailSize?: ImageSize; | |
acceptExtensions?: string[]; | |
allowExtensionsToGenerateThumbnail?: string[]; | |
}; | |
/** | |
* ファイルのinput周りを扱うhooks | |
* - ファイルサイズバリデーション | |
* - サムネイル生成 | |
* - エラー管理 | |
* | |
* TODO: | |
* - ファイルサムネイル作らないようなオプション | |
* | |
* @export | |
* @param {Props} { | |
* maxFileSize // 指定した数字を含むMB以上はバリデーションエラーになる | |
* thumbnailSize, | |
* acceptExtensions | |
* allowExtensionsToGenerateThumbnail, | |
* } | |
* @returns {[ | |
* RefObject<HTMLInputElement>, | |
* LocalFile, | |
* FileInputStates, | |
* FileInputErrors, | |
* (e: ChangeEvent<HTMLInputElement>) => void, | |
* () => void | |
* ]} | |
*/ | |
export default function useFileInput({ | |
maxFileSize, | |
thumbnailSize, | |
acceptExtensions = [], | |
allowExtensionsToGenerateThumbnail = [], | |
}: Props): [ | |
RefObject<HTMLInputElement>, | |
LocalFile, | |
FileInputStates, | |
FileInputErrors, | |
(e: ChangeEvent<HTMLInputElement>) => void, | |
() => void | |
] { | |
const inputRef = useRef<HTMLInputElement>(null); | |
const [fileName, setFileName] = useState<string | null>(null); | |
const [fileSize, setFileSize] = useState<number | null>(null); | |
const [rawBase64Image, setRawBase64Image] = useState<string | null>(null); | |
const [thumbnailBase64Image, setThumbnailBase64Image] = useState< | |
string | null | |
>(null); | |
const [imageSize, setImageSize] = useState<{ width: number; height: number }>( | |
{ width: 0, height: 0 } | |
); | |
const [state, setState] = useState<FileInputStates>(FileInputStates.STAND_BY); | |
const [error, setError] = useState<FileInputErrors>(FileInputErrors.NONE); | |
const handleDeleteImage = () => { | |
setRawBase64Image(null); | |
setThumbnailBase64Image(null); | |
setFileName(null); | |
setFileSize(null); | |
setImageSize({ width: 0, height: 0 }); | |
}; | |
const setImageData = async ( | |
image: string | null, | |
name: string, | |
size: number | |
) => { | |
setRawBase64Image(image); | |
setFileName(name); | |
setFileSize(size); | |
const ext = getExt(name); | |
const allowGenerateThumbnail = includes( | |
allowExtensionsToGenerateThumbnail, | |
ext | |
); | |
// for debug | |
console.log( | |
"[useFileInput] set image data: has image, ext, allow generate thumbnail", | |
!!image, | |
ext, | |
allowGenerateThumbnail | |
); | |
if (!image || !ext || !allowGenerateThumbnail) { | |
setThumbnailBase64Image(null); | |
setImageSize({ width: 0, height: 0 }); | |
return; | |
} | |
const imageSizeResult = await getImageSize(image); | |
// for debug | |
console.log("[useFileInput] get image size", imageSizeResult); | |
setImageSize( | |
imageSizeResult.width && imageSizeResult.height | |
? imageSizeResult | |
: { width: 0, height: 0 } | |
); | |
const resizedImage = await resizeBase64Image( | |
image, | |
thumbnailSize?.width, | |
thumbnailSize?.height | |
); | |
if (!resizedImage) { | |
console.error("[useFileInput]: resize failed"); | |
setThumbnailBase64Image(null); | |
return; | |
} | |
setThumbnailBase64Image(resizedImage); | |
}; | |
const handleChangeImage = async (e: ChangeEvent<HTMLInputElement>) => { | |
const file = e.target.files?.item(0); | |
console.log("[useFileInput]: handleChangeImage", file); | |
if (!file) { | |
return; | |
} | |
if (!maxFileSize) { | |
console.error("[useFileInput]: maxFileSize is invalid"); | |
return; | |
} | |
setState(FileInputStates.LOADING); | |
const reader = new window.FileReader(); | |
reader.onload = async ({ target }) => { | |
try { | |
const result = typeof target?.result === "string" ? target?.result : ""; | |
console.log("[useFileInput]: onload", file.name, file.size, !!result); | |
// fileの中身が変な場合 | |
if (!result) { | |
console.error("[useFileInput]: invalid file"); | |
setError(FileInputErrors.INVALID_FILE); | |
return; | |
} | |
const ext = getExt(file.name); | |
// ファイルの拡張子がなんらかの事情でおかしい場合 | |
if (!ext) { | |
console.error("[useFileInput]: validate error - invalid ext"); | |
await setImageData(null, file.name, file.size); | |
setError(FileInputErrors.NOT_ACCEPTED_EXTENSION); | |
return; | |
} | |
const isAcceptedFileExtension = includes(acceptExtensions, ext); | |
// ファイルの拡張子がacceptされたものではない場合 | |
if (!isAcceptedFileExtension) { | |
console.error( | |
"[useFileInput]: validate error - not accepted file extension" | |
); | |
await setImageData(null, file.name, file.size); | |
setError(FileInputErrors.NOT_ACCEPTED_EXTENSION); | |
return; | |
} | |
// ファイルサイズのバリデーションエラーの場合 | |
if (calcFileMegaByteSize(file) >= maxFileSize) { | |
console.error("[useFileInput]: validate error - file size"); | |
await setImageData(null, file.name, file.size); | |
setError(FileInputErrors.LARGE_FILE_SIZE); | |
return; | |
} | |
// バリデーションが通った場合 | |
await setImageData(result, file.name, file.size); | |
setError(FileInputErrors.NONE); | |
} catch (err) { | |
// 何かしら予期せぬエラーが発生したらinvalidとしてしまう | |
setError(FileInputErrors.INVALID_FILE); | |
handleDeleteImage(); | |
} finally { | |
// 諸々の処理が終わったらアップロード可能な状態とする | |
setState(FileInputStates.STAND_BY); | |
} | |
}; | |
// 読み込みエラー | |
reader.onerror = () => { | |
console.error("[useFileInput]: onerror", reader.error); | |
reader.abort(); | |
setState(FileInputStates.STAND_BY); | |
setError(FileInputErrors.FAILED_TO_READ_FILE); | |
}; | |
reader.readAsDataURL(file); | |
}; | |
return [ | |
inputRef, | |
{ | |
name: fileName, | |
size: fileSize, | |
rawBase64Image, | |
thumbnailBase64Image, | |
imageSize, | |
}, | |
state, | |
error, | |
handleChangeImage, | |
handleDeleteImage, | |
]; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment