Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat/#369] 이미지 크롭 기능 추가 #383

Merged
merged 10 commits into from
Aug 31, 2024
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"react-components": "^0.5.1",
"react-dom": "^18.3.1",
"react-helmet-async": "^2.0.5",
"react-image-crop": "^11.0.6",
"react-lottie-player": "^2.0.0",
"react-router-dom": "^6.24.0",
"shelljs": "^0.8.5",
Expand Down
47 changes: 47 additions & 0 deletions src/components/commons/imageEditor/ImageEditor.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import styled from "styled-components";
import { Generators } from "@styles/generator";
import ReactCrop, { Crop } from "react-image-crop";

export const ModalContainer = styled.div`
${Generators.flexGenerator("column", "center", "center")}
position: fixed;
z-index: 1;
gap: 2rem;

background-color: rgb(15 15 15 / 70%);
inset: 0;
`;

export const OriginImage = styled.img`
width: 32rem;
`;

export const CustomReactCrop = styled(ReactCrop)<{
aspectRatio: number;
calculatedSize: { width: number; height: number };
}>`
position: relative;

.ReactCrop__drag-handle {
width: 1rem;
height: 1rem;

background: ${({ theme }) => theme.colors.main_pink_400};
border: none;
}

.ReactCrop__crop-selection::before {
position: absolute;
top: 50%;
left: 50%;
box-sizing: border-box;
width: ${({ calculatedSize }) => calculatedSize.width}rem;
height: ${({ calculatedSize }) => calculatedSize.height}rem;

transform: translate(-50%, -50%);
border: 0.2rem solid ${({ theme }) => theme.colors.white};
border-radius: 0.6rem;

content: "";
}
`;
169 changes: 169 additions & 0 deletions src/components/commons/imageEditor/ImageEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import React, { useState, useRef, useEffect } from "react";
import * as S from "./ImageEditor.styled";
import { Crop, PixelCrop, centerCrop, makeAspectCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import Button from "@components/commons/button/Button";

interface ImageEditorProps {
file: string;
aspectRatio: number;
onCropped: (croppedImageUrl: string) => void;
}

const ImageEditor = ({ file, aspectRatio, onCropped }: ImageEditorProps) => {
const [crop, setCrop] = useState<Crop>({
unit: "%",
x: 0,
y: 0,
width: 100,
height: 100,
});
const [imageSize, setImageSize] = useState<{ width: number; height: number }>({
width: 0,
height: 0,
});
const [calculatedSize, setCalculatedSize] = useState<{ width: number; height: number }>({
width: 0,
height: 0,
});
const [croppedImageUrl, setCroppedImageUrl] = useState<string | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);

const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
// 이미지의 원래 너비와 높이 가져오기
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
if (e.currentTarget) {
setImageSize({ width, height });
}

// 이미지의 중앙에 크롭 영역 설정
const centerCropped = centerCrop(
makeAspectCrop(
// 크롭 영역 설정
{
unit: "%", // 크롭 단위
width: 100, // 크롭 영역 너비
height: 100,
},
width / height,
width,
height
),
width,
height
);

setCrop(centerCropped); // 중앙에 설정된 크롭 영역을 상태에 반영
};

useEffect(() => {
if (imageSize.width > 0 && imageSize.height > 0) {
// 렌더링된 이미지 크기 계산
const renderedWidth = 32;
const renderedHeight = (imageSize.height / imageSize.width) * renderedWidth;

// 100으로 초기화했던 크롭 영역의 최대 크기 계산
const maxWidth = (crop.width / 100) * renderedWidth;
const maxHeight = (crop.height / 100) * renderedHeight;

// 최적의 width, height 계산
let width, height;
if (maxWidth / aspectRatio > maxHeight) {
height = maxHeight;
width = height * aspectRatio;
} else {
width = maxWidth;
height = width / aspectRatio;
}

setCalculatedSize({ width, height });
}
}, [imageSize, crop]);

// 이미지 크롭 업데이트
const onCropChange = (crop: Crop, percentCrop: Crop) => {
setCrop(percentCrop);
};

const onCropComplete = (crop: PixelCrop, percentCrop: Crop) => {
makeClientCrop(crop);
};

const makeClientCrop = async (crop: PixelCrop) => {
if (imageRef.current && crop.width && crop.height) {
const croppedImage = await getCroppedImg(imageRef.current, crop, "newFile.jpeg");
setCroppedImageUrl(croppedImage);
}
};

// 크롭된 이미지를 생성
const getCroppedImg = (
image: HTMLImageElement,
crop: PixelCrop,
fileName: string
): Promise<string> => {
// 캔버스에 이미지 생성
const canvas = document.createElement("canvas");
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
// 캔버스 크기를 원본 해상도 기준으로 설정
const pixelRatio = window.devicePixelRatio; // 실제 픽셀 크기와 CSS 픽셀 크기 간의 비율
canvas.width = crop.width * scaleX * pixelRatio;
canvas.height = crop.height * scaleY * pixelRatio;
const ctx = canvas.getContext("2d");

// 고해상도 이미지를 유지하기 위해 canvas에 스케일 적용
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.imageSmoothingQuality = "high";

ctx.drawImage(
// 원본 이미지 영역
image,
crop.x * scaleX, // 크롭 시작 x 좌표
crop.y * scaleY, // 크롭 시작 y 좌표
crop.width * scaleX, // 크롭할 이미지의 가로 길이
crop.height * scaleY, // 크롭할 이미지의 세로 길이
// 캔버스 영역
0, // 캔버스에서 이미지 시작 x 좌표
0, // 캔버스에서 이미지 시작 y 좌표
crop.width * scaleX, // 캔버스에서 이미지의 가로 길이
crop.height * scaleY // 캔버스에서 이미지의 세로 길이
);

return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
console.error("이미지 없음");
return reject(new Error("이미지 없음"));
}
const fileUrl = URL.createObjectURL(blob);
resolve(fileUrl);
}, "image/jpeg");
});
};

const handleComplete = () => {
if (croppedImageUrl) {
onCropped(croppedImageUrl); // 크롭된 이미지 URL을 부모 컴포넌트로 전달
} else {
onCropped(file); // 크롭하지 않으면 원래 URL 전달
}
};

return (
<S.ModalContainer>
<S.CustomReactCrop
crop={crop}
onChange={onCropChange}
onComplete={onCropComplete}
ruleOfThirds={true} // 삼분법선
calculatedSize={calculatedSize}
>
<S.OriginImage src={file} alt="Original" onLoad={onImageLoad} ref={imageRef} />
</S.CustomReactCrop>
<Button onClick={handleComplete}>완료하기</Button>
</S.ModalContainer>
);
};

export default ImageEditor;
57 changes: 39 additions & 18 deletions src/pages/register/components/PosterThumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ChangeEvent, useEffect, useState } from "react";
import * as S from "../Register.styled";
import { IconCamera } from "@assets/svgs";
import Spacing from "@components/commons/spacing/Spacing";
import ImageEditor from "@components/commons/imageEditor/ImageEditor";

interface PosterThumbnailProps {
value?: string | undefined;
Expand All @@ -12,6 +13,7 @@ const PosterThumbnail = ({ value, onImageUpload }: PosterThumbnailProps) => {
const [postImg, setPostImg] = useState<File | null>(null);
const [previewImg, setPreviewImg] = useState<string | null>(value || null);
const [inputKey, setInputKey] = useState<number>(Date.now());
const [openImageModal, setOpenImageModal] = useState(false);

useEffect(() => {
setPreviewImg(value || null);
Expand All @@ -26,6 +28,7 @@ const PosterThumbnail = ({ value, onImageUpload }: PosterThumbnailProps) => {
setPostImg(file);
setPreviewImg(imageUrl);
onImageUpload(imageUrl);
setOpenImageModal(true);
};

fileReader.readAsDataURL(file);
Expand All @@ -39,25 +42,43 @@ const PosterThumbnail = ({ value, onImageUpload }: PosterThumbnailProps) => {
onImageUpload("");
};

// ImageEditor에서 크롭된 이미지 URL을 받아서 상태 업데이트
const handleCroppedImage = (croppedImageUrl: string) => {
setPreviewImg(croppedImageUrl);
setOpenImageModal(false); // 모달 닫기
onImageUpload(croppedImageUrl); // 부모 컴포넌트로 전달
};

return (
<S.InputRegisterBox $marginBottom={2.8}>
<S.InputTitle>포스터 썸네일</S.InputTitle>
<S.InputDescription>한 장만 등록 가능합니다.</S.InputDescription>
<S.InputDescription $warning={true}>*포스터 썸네일은 수정불가합니다.</S.InputDescription>
<Spacing marginBottom="1.4" />
<S.FileInputWrapper>
<S.HiddenFileInput key={inputKey} type="file" id="file" onChange={uploadFile} />
<S.CustomFileInput htmlFor="file">
<IconCamera width={"3.2rem"} />
</S.CustomFileInput>
{previewImg && (
<S.PreviewImageWrapper>
<S.PreviewImage src={previewImg} alt="Preview" />
<S.RemoveImageButton onClick={removeImage} />
</S.PreviewImageWrapper>
)}
</S.FileInputWrapper>
</S.InputRegisterBox>
<>
<S.InputRegisterBox $marginBottom={2.8}>
<S.InputTitle>포스터 썸네일</S.InputTitle>
<S.InputDescription>한 장만 등록 가능합니다.</S.InputDescription>
<S.InputDescription $warning={true}>*포스터 썸네일은 수정불가합니다.</S.InputDescription>
<Spacing marginBottom="1.4" />
<S.FileInputWrapper>
<S.HiddenFileInput
key={inputKey}
type="file"
id="file"
accept="images/*"
onChange={uploadFile}
/>
<S.CustomFileInput htmlFor="file">
<IconCamera width={"3.2rem"} />
</S.CustomFileInput>
{previewImg && (
<S.PreviewImageWrapper>
<S.PreviewImage src={previewImg} alt="Preview" />
<S.RemoveImageButton onClick={removeImage} />
</S.PreviewImageWrapper>
)}
</S.FileInputWrapper>
</S.InputRegisterBox>
{openImageModal && (
<ImageEditor file={previewImg} aspectRatio={3 / 4} onCropped={handleCroppedImage} />
)}
</>
);
};

Expand Down
Loading
Loading