Skip to content

Commit

Permalink
Merge pull request #84 from Minsoek96/dev/image
Browse files Browse the repository at this point in the history
feat : optimizedPhoto
  • Loading branch information
bunzzeok authored Dec 13, 2024
2 parents be32302 + 468a4ce commit f09a55b
Show file tree
Hide file tree
Showing 2 changed files with 233 additions and 30 deletions.
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 };

0 comments on commit f09a55b

Please sign in to comment.