Skip to content

Commit f09a55b

Browse files
authored
Merge pull request #84 from Minsoek96/dev/image
feat : optimizedPhoto
2 parents be32302 + 468a4ce commit f09a55b

File tree

2 files changed

+233
-30
lines changed

2 files changed

+233
-30
lines changed

src/_app/pages/CreateMessagePage/Steps/PhotoInputStep/index.tsx

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,55 +4,79 @@ import StepHeader from "@/components/Funnel/StepHeader";
44
import useToast from "@/hooks/useToast";
55
import { Photo } from "@/types/client";
66
import { isNill, isUndefined } from "@/utils";
7+
import { convertToWebP } from "@/utils/convertToWebP";
78
import clsx from "clsx";
8-
import { ChangeEvent, useRef } from "react";
9+
import { ChangeEvent, useCallback, useRef } from "react";
910
import { useWindowSize } from "react-use";
1011

1112
interface Props {
1213
photo: Photo | undefined;
13-
setPhoto: (newPhoto: Photo) => void;
14+
setPhoto: (newPhoto: Photo | undefined) => void;
15+
maxSizeMB?: number;
16+
maxWidth?: number;
17+
maxHeight?: number;
18+
quality?: number;
1419
}
15-
const PhotoInputStep = ({ photo, setPhoto }: Props) => {
20+
const PhotoInputStep = ({
21+
photo,
22+
setPhoto,
23+
maxSizeMB = 5,
24+
maxWidth = 1920,
25+
maxHeight = 1080,
26+
quality = 0.8,
27+
}: Props) => {
1628
const { setGlobalLoading } = useLoadingOverlay();
1729
const inputRef = useRef<HTMLInputElement>(null);
1830
const { showToast } = useToast();
19-
const onClickUpload = () => {
20-
if (isNill(inputRef.current)) {
21-
return;
22-
}
2331

32+
const handleImageConvert = useCallback(
33+
async (file: File): Promise<Photo> => {
34+
return convertToWebP(file, { maxWidth, maxHeight, quality });
35+
},
36+
[maxWidth, maxHeight, quality]
37+
);
38+
39+
const onClickUpload = () => {
40+
if (isNill(inputRef.current)) return;
2441
inputRef.current.click();
2542
};
26-
const onChange = async (event: ChangeEvent<HTMLInputElement>) => {
27-
if (isNill(event.target) || isNill(event.target.files)) {
28-
showToast("이미지를 불러오는 데 실패했어요.", "error");
2943

30-
return;
31-
}
44+
const onChange = async (event: ChangeEvent<HTMLInputElement>) => {
45+
try {
46+
if (isNill(event.target) || isNill(event.target.files)) {
47+
showToast("이미지를 불러오는 데 실패했어요.", "error");
48+
return;
49+
}
50+
const file = event.target.files[0];
51+
setGlobalLoading(true);
3252

33-
const file = event.target.files[0];
34-
setGlobalLoading(true);
53+
const maxSize = maxSizeMB * 1024 * 1024; // 10MB
54+
if (file.size > maxSize) {
55+
setGlobalLoading(false);
56+
showToast("이미지 용량이 너무 커요.", "error");
57+
return;
58+
}
3559

36-
const maxSize = 5 * 1024 * 1024; // 10MB
37-
if (file.size > maxSize) {
60+
const optimizedPhoto = await handleImageConvert(file);
61+
setPhoto(optimizedPhoto);
62+
} catch (error) {
63+
showToast("이미지 처리 중 오류가 발생했어요.", "error");
64+
} finally {
3865
setGlobalLoading(false);
39-
showToast("이미지 용량이 너무 커요.", "error");
40-
41-
return;
66+
if (inputRef.current) {
67+
inputRef.current.value = "";
68+
}
4269
}
70+
};
4371

44-
const fileUrl = URL.createObjectURL(file);
45-
46-
const img = new window.Image();
47-
img.src = fileUrl;
48-
img.onload = () =>
49-
setPhoto({
50-
file,
51-
url: fileUrl,
52-
});
53-
setGlobalLoading(false);
72+
const handleRemoveImage = () => {
73+
if (photo?.url) {
74+
URL.revokeObjectURL(photo.url);
75+
}
76+
setPhoto(undefined);
5477
};
5578

79+
5680
const { height } = useWindowSize();
5781

5882
return (
@@ -84,12 +108,16 @@ const PhotoInputStep = ({ photo, setPhoto }: Props) => {
84108
<img
85109
src={photo.url}
86110
className={clsx("w-full h-full rounded-[15px] object-contain")}
111+
alt="업로드된 이미지"
112+
onDoubleClick={handleRemoveImage}
87113
/>
88114
)}
89115
</div>
90116
<div className="h-[18px]" />
91117
<p className="text-[#A1A1A1] text-[14px] text-center">
92-
사진은 최대 1장, 5mb까지 업로드 가능해요.
118+
{isUndefined(photo)
119+
? `사진은 최대 1장, ${maxSizeMB}mb까지 업로드 가능해요.`
120+
: "이미지를 더블클릭하면 제거할 수 있어요."}
93121
</p>
94122
</div>
95123
<input

src/utils/convertToWebP.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { Photo } from "@/types/client";
2+
3+
interface ImageDimension {
4+
width: number;
5+
height: number;
6+
}
7+
8+
interface ImageConverterConfig {
9+
maxWidth: number;
10+
maxHeight: number;
11+
quality: number;
12+
}
13+
14+
/**
15+
* 원본 이미지의 종횡비를 유지하면서 최대 허용 크기 조정
16+
* @param original - 원본 이미지 크기
17+
* @param max - 최대 허용 크기
18+
* @returns 조정된 이미지 크기
19+
*/
20+
const calculateAspectRatio = (
21+
original: ImageDimension,
22+
max: ImageDimension
23+
): ImageDimension => {
24+
let { width, height } = original;
25+
26+
if (width > max.width) {
27+
height = (height * max.width) / width;
28+
width = max.width;
29+
}
30+
31+
if (height > max.height) {
32+
width = (width * max.height) / height;
33+
height = max.height;
34+
}
35+
36+
return {
37+
width: Math.floor(width),
38+
height: Math.floor(height),
39+
};
40+
};
41+
42+
/**
43+
* 지정된 크기의 Canvas 엘리먼트 생성
44+
* @param dimension - Canvas 크기
45+
* @returns HTMLCanvasElement
46+
*/
47+
const createCanvas = (dimension: ImageDimension): HTMLCanvasElement => {
48+
const canvas = document.createElement("canvas");
49+
canvas.width = dimension.width;
50+
canvas.height = dimension.height;
51+
return canvas;
52+
};
53+
54+
/**
55+
* Canvas에 이미지를 그림
56+
* @param canvas - 대상 Canvas 엘리먼트
57+
* @param image - 그릴 이미지
58+
* @throws Canvas context를 얻을 수 없는 경우
59+
* @returns 이미지가 그려진 Canvas
60+
*/
61+
const drawImageToCanvas = (
62+
canvas: HTMLCanvasElement,
63+
image: HTMLImageElement
64+
): HTMLCanvasElement | never => {
65+
const ctx = canvas.getContext("2d");
66+
if (!ctx) throw new Error("Canvas context not available");
67+
68+
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
69+
return canvas;
70+
};
71+
72+
73+
//TODO : 서버 정책으로 인한 avif ,webp 등 400에러
74+
//code : 504 , message : 이미지 파일만 업로드 가능합니다.
75+
76+
/**
77+
* Canvas를 WebP 형식으로 변환
78+
* @param canvas - 변환할 Canvas
79+
* @param fileName - 원본 파일 이름
80+
* @param quality - 압축 품질 (0~1)
81+
* @returns Promise<Photo>
82+
*/
83+
const canvasToWebP = (
84+
canvas: HTMLCanvasElement,
85+
fileName: string,
86+
quality: number
87+
): Promise<Photo> =>
88+
new Promise((resolve, reject) => {
89+
canvas.toBlob(
90+
(blob) => {
91+
if (!blob) {
92+
reject(new Error("Blob creation failed"));
93+
return;
94+
}
95+
96+
const webpFile = new File(
97+
[blob],
98+
fileName.replace(/\.[^/.]+$/, ".jpeg"),
99+
{
100+
type: "image/jpeg",
101+
}
102+
);
103+
const webpUrl = URL.createObjectURL(blob);
104+
105+
resolve({
106+
file: webpFile,
107+
url: webpUrl,
108+
});
109+
},
110+
"image/jpeg",
111+
quality
112+
);
113+
});
114+
115+
const loadImage = (objectUrl: string): Promise<HTMLImageElement> =>
116+
new Promise((resolve, reject) => {
117+
const img = new Image();
118+
img.onload = () => resolve(img);
119+
img.onerror = () => reject(new Error("Image loading failed"));
120+
img.src = objectUrl;
121+
});
122+
123+
/**
124+
* File 객체를 Data URL로 변환
125+
* @param file - 변환할 File 객체
126+
* @returns Promise<string>
127+
*/
128+
const fileToObjectUrl = (file: File): Promise<string> =>
129+
new Promise((resolve, reject) => {
130+
const reader = new FileReader();
131+
reader.onload = (e) => resolve(e.target?.result as string);
132+
reader.onerror = () => reject(new Error("File reading failed"));
133+
reader.readAsDataURL(file);
134+
});
135+
136+
/**
137+
* 이미지 파일을 WebP 형식으로 변환
138+
*
139+
* @param file - 변환할 이미지 파일
140+
* @param config - 변환 설정
141+
* @param config.maxWidth - 최대 허용 너비
142+
* @param config.maxHeight - 최대 허용 높이
143+
* @param config.quality - 압축 품질 (0~1)
144+
*
145+
* @returns Promise<Photo> - 변환된 이미지 정보
146+
* @throws {Error} 이미지 로드 실패, Canvas 생성 실패 등의 경우
147+
*
148+
* @example
149+
* const optimizedImage = await convertToWebP(file, {
150+
* maxWidth: 1920,
151+
* maxHeight: 1080,
152+
* quality: 0.8
153+
* });
154+
*/
155+
const convertToWebP = async (
156+
file: File,
157+
config: ImageConverterConfig
158+
): Promise<Photo> => {
159+
const objectUrl = await fileToObjectUrl(file);
160+
const image = await loadImage(objectUrl);
161+
162+
const dimension = calculateAspectRatio(
163+
{ width: image.width, height: image.height },
164+
{ width: config.maxWidth, height: config.maxHeight }
165+
);
166+
167+
const canvas = createCanvas(dimension);
168+
const drawnCanvas = drawImageToCanvas(canvas, image);
169+
const webpImage = await canvasToWebP(drawnCanvas, file.name, config.quality);
170+
171+
URL.revokeObjectURL(objectUrl);
172+
return webpImage;
173+
};
174+
175+
export { convertToWebP };

0 commit comments

Comments
 (0)