diff --git a/src/_app/pages/CreateMessagePage/Steps/PhotoInputStep/index.tsx b/src/_app/pages/CreateMessagePage/Steps/PhotoInputStep/index.tsx index ad153c4..25d23a1 100644 --- a/src/_app/pages/CreateMessagePage/Steps/PhotoInputStep/index.tsx +++ b/src/_app/pages/CreateMessagePage/Steps/PhotoInputStep/index.tsx @@ -4,55 +4,79 @@ import StepHeader from "@/components/Funnel/StepHeader"; import useToast from "@/hooks/useToast"; import { Photo } from "@/types/client"; import { isNill, isUndefined } from "@/utils"; +import { convertToWebP } from "@/utils/convertToWebP"; import clsx from "clsx"; -import { ChangeEvent, useRef } from "react"; +import { ChangeEvent, useCallback, useRef } from "react"; import { useWindowSize } from "react-use"; interface Props { photo: Photo | undefined; - setPhoto: (newPhoto: Photo) => void; + setPhoto: (newPhoto: Photo | undefined) => void; + maxSizeMB?: number; + maxWidth?: number; + maxHeight?: number; + quality?: number; } -const PhotoInputStep = ({ photo, setPhoto }: Props) => { +const PhotoInputStep = ({ + photo, + setPhoto, + maxSizeMB = 5, + maxWidth = 1920, + maxHeight = 1080, + quality = 0.8, +}: Props) => { const { setGlobalLoading } = useLoadingOverlay(); const inputRef = useRef(null); const { showToast } = useToast(); - const onClickUpload = () => { - if (isNill(inputRef.current)) { - return; - } + const handleImageConvert = useCallback( + async (file: File): Promise => { + return convertToWebP(file, { maxWidth, maxHeight, quality }); + }, + [maxWidth, maxHeight, quality] + ); + + const onClickUpload = () => { + if (isNill(inputRef.current)) return; inputRef.current.click(); }; - const onChange = async (event: ChangeEvent) => { - if (isNill(event.target) || isNill(event.target.files)) { - showToast("이미지를 불러오는 데 실패했어요.", "error"); - return; - } + const onChange = async (event: ChangeEvent) => { + try { + if (isNill(event.target) || isNill(event.target.files)) { + showToast("이미지를 불러오는 데 실패했어요.", "error"); + return; + } + const file = event.target.files[0]; + setGlobalLoading(true); - const file = event.target.files[0]; - setGlobalLoading(true); + const maxSize = maxSizeMB * 1024 * 1024; // 10MB + if (file.size > maxSize) { + setGlobalLoading(false); + showToast("이미지 용량이 너무 커요.", "error"); + return; + } - const maxSize = 5 * 1024 * 1024; // 10MB - if (file.size > maxSize) { + const optimizedPhoto = await handleImageConvert(file); + setPhoto(optimizedPhoto); + } catch (error) { + showToast("이미지 처리 중 오류가 발생했어요.", "error"); + } finally { setGlobalLoading(false); - showToast("이미지 용량이 너무 커요.", "error"); - - return; + if (inputRef.current) { + inputRef.current.value = ""; + } } + }; - const fileUrl = URL.createObjectURL(file); - - const img = new window.Image(); - img.src = fileUrl; - img.onload = () => - setPhoto({ - file, - url: fileUrl, - }); - setGlobalLoading(false); + const handleRemoveImage = () => { + if (photo?.url) { + URL.revokeObjectURL(photo.url); + } + setPhoto(undefined); }; + const { height } = useWindowSize(); return ( @@ -84,12 +108,16 @@ const PhotoInputStep = ({ photo, setPhoto }: Props) => { 업로드된 이미지 )}

- 사진은 최대 1장, 5mb까지 업로드 가능해요. + {isUndefined(photo) + ? `사진은 최대 1장, ${maxSizeMB}mb까지 업로드 가능해요.` + : "이미지를 더블클릭하면 제거할 수 있어요."}

{ + let { width, height } = original; + + if (width > max.width) { + height = (height * max.width) / width; + width = max.width; + } + + if (height > max.height) { + width = (width * max.height) / height; + height = max.height; + } + + return { + width: Math.floor(width), + height: Math.floor(height), + }; +}; + +/** + * 지정된 크기의 Canvas 엘리먼트 생성 + * @param dimension - Canvas 크기 + * @returns HTMLCanvasElement + */ +const createCanvas = (dimension: ImageDimension): HTMLCanvasElement => { + const canvas = document.createElement("canvas"); + canvas.width = dimension.width; + canvas.height = dimension.height; + return canvas; +}; + +/** + * Canvas에 이미지를 그림 + * @param canvas - 대상 Canvas 엘리먼트 + * @param image - 그릴 이미지 + * @throws Canvas context를 얻을 수 없는 경우 + * @returns 이미지가 그려진 Canvas + */ +const drawImageToCanvas = ( + canvas: HTMLCanvasElement, + image: HTMLImageElement +): HTMLCanvasElement | never => { + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas context not available"); + + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + return canvas; +}; + + +//TODO : 서버 정책으로 인한 avif ,webp 등 400에러 +//code : 504 , message : 이미지 파일만 업로드 가능합니다. + +/** + * Canvas를 WebP 형식으로 변환 + * @param canvas - 변환할 Canvas + * @param fileName - 원본 파일 이름 + * @param quality - 압축 품질 (0~1) + * @returns Promise + */ +const canvasToWebP = ( + canvas: HTMLCanvasElement, + fileName: string, + quality: number +): Promise => + new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error("Blob creation failed")); + return; + } + + const webpFile = new File( + [blob], + fileName.replace(/\.[^/.]+$/, ".jpeg"), + { + type: "image/jpeg", + } + ); + const webpUrl = URL.createObjectURL(blob); + + resolve({ + file: webpFile, + url: webpUrl, + }); + }, + "image/jpeg", + quality + ); + }); + +const loadImage = (objectUrl: string): Promise => + new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error("Image loading failed")); + img.src = objectUrl; + }); + +/** + * File 객체를 Data URL로 변환 + * @param file - 변환할 File 객체 + * @returns Promise + */ +const fileToObjectUrl = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target?.result as string); + reader.onerror = () => reject(new Error("File reading failed")); + reader.readAsDataURL(file); + }); + +/** + * 이미지 파일을 WebP 형식으로 변환 + * + * @param file - 변환할 이미지 파일 + * @param config - 변환 설정 + * @param config.maxWidth - 최대 허용 너비 + * @param config.maxHeight - 최대 허용 높이 + * @param config.quality - 압축 품질 (0~1) + * + * @returns Promise - 변환된 이미지 정보 + * @throws {Error} 이미지 로드 실패, Canvas 생성 실패 등의 경우 + * + * @example + * const optimizedImage = await convertToWebP(file, { + * maxWidth: 1920, + * maxHeight: 1080, + * quality: 0.8 + * }); + */ +const convertToWebP = async ( + file: File, + config: ImageConverterConfig +): Promise => { + const objectUrl = await fileToObjectUrl(file); + const image = await loadImage(objectUrl); + + const dimension = calculateAspectRatio( + { width: image.width, height: image.height }, + { width: config.maxWidth, height: config.maxHeight } + ); + + const canvas = createCanvas(dimension); + const drawnCanvas = drawImageToCanvas(canvas, image); + const webpImage = await canvasToWebP(drawnCanvas, file.name, config.quality); + + URL.revokeObjectURL(objectUrl); + return webpImage; +}; + +export { convertToWebP };