Skip to content

Instantly share code, notes, and snippets.

@takumifukasawa
Created November 4, 2020 13:51
Show Gist options
  • Save takumifukasawa/b2a6ae3b1516fa831e7df4e247693d84 to your computer and use it in GitHub Desktop.
Save takumifukasawa/b2a6ae3b1516fa831e7df4e247693d84 to your computer and use it in GitHub Desktop.
React: custom hooks of managing file input with validation and generate thumbnails
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