Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 58 additions & 30 deletions src/_app/pages/CreateMessagePage/Steps/PhotoInputStep/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>(null);
const { showToast } = useToast();
const onClickUpload = () => {
if (isNill(inputRef.current)) {
return;
}

const handleImageConvert = useCallback(
async (file: File): Promise<Photo> => {
return convertToWebP(file, { maxWidth, maxHeight, quality });
},
[maxWidth, maxHeight, quality]
);

const onClickUpload = () => {
if (isNill(inputRef.current)) return;
inputRef.current.click();
};
const onChange = async (event: ChangeEvent<HTMLInputElement>) => {
if (isNill(event.target) || isNill(event.target.files)) {
showToast("이미지를 불러오는 데 실패했어요.", "error");

return;
}
const onChange = async (event: ChangeEvent<HTMLInputElement>) => {
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 (
Expand Down Expand Up @@ -84,12 +108,16 @@ const PhotoInputStep = ({ photo, setPhoto }: Props) => {
<img
src={photo.url}
className={clsx("w-full h-full rounded-[15px] object-contain")}
alt="업로드된 이미지"
onDoubleClick={handleRemoveImage}
/>
)}
</div>
<div className="h-[18px]" />
<p className="text-[#A1A1A1] text-[14px] text-center">
사진은 최대 1장, 5mb까지 업로드 가능해요.
{isUndefined(photo)
? `사진은 최대 1장, ${maxSizeMB}mb까지 업로드 가능해요.`
: "이미지를 더블클릭하면 제거할 수 있어요."}
</p>
</div>
<input
Expand Down
175 changes: 175 additions & 0 deletions src/utils/convertToWebP.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { Photo } from "@/types/client";

interface ImageDimension {
width: number;
height: number;
}

interface ImageConverterConfig {
maxWidth: number;
maxHeight: number;
quality: number;
}

/**
* 원본 이미지의 종횡비를 유지하면서 최대 허용 크기 조정
* @param original - 원본 이미지 크기
* @param max - 최대 허용 크기
* @returns 조정된 이미지 크기
*/
const calculateAspectRatio = (
original: ImageDimension,
max: ImageDimension
): ImageDimension => {
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<Photo>
*/
const canvasToWebP = (
canvas: HTMLCanvasElement,
fileName: string,
quality: number
): Promise<Photo> =>
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<HTMLImageElement> =>
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<string>
*/
const fileToObjectUrl = (file: File): Promise<string> =>
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<Photo> - 변환된 이미지 정보
* @throws {Error} 이미지 로드 실패, Canvas 생성 실패 등의 경우
*
* @example
* const optimizedImage = await convertToWebP(file, {
* maxWidth: 1920,
* maxHeight: 1080,
* quality: 0.8
* });
*/
const convertToWebP = async (
file: File,
config: ImageConverterConfig
): Promise<Photo> => {
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 };
Loading