Skip to content

Commit

Permalink
Merge pull request #657 from Bamdoliro/bug/#634
Browse files Browse the repository at this point in the history
feat(user): 사진 규격보다 크면 규격만큼 자르는 기능 개발
  • Loading branch information
junghongseop authored Jul 26, 2024
2 parents a0250db + 5682a87 commit 6176002
Show file tree
Hide file tree
Showing 7 changed files with 571 additions and 14 deletions.
3 changes: 2 additions & 1 deletion apps/user/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@
"typescript": "5.1.3"
},
"dependencies": {
"@maru/design-token": "workspace:*",
"@maru/hooks": "workspace:*",
"@maru/icon": "workspace:*",
"@maru/design-token": "workspace:*",
"@maru/ui": "workspace:*",
"@maru/utils": "workspace:*",
"@suspensive/react": "^1.18.1",
Expand All @@ -50,6 +50,7 @@
"react": "18.2.0",
"react-daum-postcode": "^3.1.3",
"react-dom": "18.2.0",
"react-easy-crop": "^5.0.7",
"recoil": "^0.7.7",
"styled-components": "^6.0.3"
}
Expand Down
152 changes: 152 additions & 0 deletions apps/user/src/components/form/CropImageModal/CropImageModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import React, { useState, useCallback } from 'react';
import { color } from '@maru/design-token';
import { Button, Column, Text } from '@maru/ui';
import { flex } from '@maru/utils';
import styled from 'styled-components';
import type { Area } from 'react-easy-crop';
import Cropper from 'react-easy-crop';
import { getCropImg } from '@/utils';

interface Props {
zoom: number;
isOpen: boolean;
imageSrc: string;
onClose: () => void;
onCropComplete: (croppedImage: Blob) => void;
}

const CropImageModal = ({ zoom, isOpen, imageSrc, onClose, onCropComplete }: Props) => {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [cropArea, setCropArea] = useState<Area | null>(null);
const [currentZoom, setCurrentZoom] = useState(zoom);

const onCropCompleteInternal = useCallback(
(cropArea: Area, croppedAreaPixels: Area) => {
setCropArea(croppedAreaPixels);
},
[]
);

const handleApplyCrop = useCallback(async () => {
if (!cropArea) return;
const croppedImage = await getCropImg(imageSrc, cropArea, 117, 156);

const response = await fetch(croppedImage as string);
const blob = await response.blob();

onCropComplete(blob);
onClose();
}, [imageSrc, cropArea, onCropComplete, onClose]);

const handleZoomChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCurrentZoom(Number(e.target.value));
};

if (!isOpen) return null;

return (
<BlurBackground>
<ModalContainer>
<Column alignItems="center" gap={16}>
<CropImageModalStyle>
<Cropper
image={imageSrc}
crop={crop}
zoom={currentZoom}
aspect={117 / 156}
onCropChange={setCrop}
onCropComplete={onCropCompleteInternal}
onZoomChange={setCurrentZoom}
style={{
containerStyle: { width: '100%', height: '100%', position: 'relative' },
}}
/>
</CropImageModalStyle>
<ZoomBoxStyle>
<InputStyle
type="range"
min="1"
max="3"
step="0.1"
value={currentZoom}
onChange={handleZoomChange}
/>
</ZoomBoxStyle>
<Button onClick={handleApplyCrop}>
<Text fontType="btn1" color={color.white}>
사진 자르기 적용
</Text>
</Button>
</Column>
</ModalContainer>
</BlurBackground>
);
};

export default CropImageModal;

const BlurBackground = styled.div`
display: flex;
justify-content: center;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
z-index: 1000;
`;

const ModalContainer = styled.div`
margin-top: 10%;
padding: 20px;
border-radius: 10px;
max-width: 468px;
width: 100%;
text-align: center;
height: 357px;
`;

const CropImageModalStyle = styled.div`
${flex({ flexDirection: 'column', justifyContent: 'center' })}
width: 100%;
height: 400px;
border-radius: 6px;
border: 3px solid ${color.gray300};
position: relative;
overflow: hidden;
`;

const ZoomBoxStyle = styled.div`
background-color: ${color.white};
width: 80%;
height: 60px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
padding-left: 20px;
padding-right: 20px;
`;

const InputStyle = styled.input.attrs({ type: 'range' })`
-webkit-appearance: none;
appearance: none;
width: 100%;
background: ${color.gray300};
border-radius: 999px;
height: 8px;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: ${color.white};
border: 1px solid ${color.maruDefault};
border-radius: 50%;
position: relative;
cursor: pointer;
box-shadow: 0 0 0 2px ${color.maruLightBlue};
}
`;
63 changes: 55 additions & 8 deletions apps/user/src/components/form/ProfileUploader/ProfileUploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { flex } from '@maru/utils';
import type { ChangeEventHandler, DragEvent } from 'react';
import { useState, useEffect } from 'react';
import styled, { css } from 'styled-components';
import CropImageModal from '../CropImageModal/CropImageModal';

const ProfileUploader = ({
onPhotoUpload,
Expand All @@ -20,12 +21,14 @@ const ProfileUploader = ({
const [isDragging, setIsDragging] = useState(false);
const { openFileUploader: openImageFileUploader, ref: imageUploaderRef } =
useOpenFileUploader();
const { uploadProfileImageMutate } = useUploadProfileImageMutation();

const [imageSrc, setImageSrc] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [imagePreviewUrl, setImagePreviewUrl] = useState(
form.applicant.identificationPictureUri
);

const { uploadProfileImageMutate } = useUploadProfileImageMutation();

const uploadProfileImageFile = (image: FormData) => {
uploadProfileImageMutate(image, {
onSuccess: (res) => {
Expand All @@ -46,9 +49,25 @@ const ProfileUploader = ({
const handleImageFileChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const { files } = e.target;
if (!files || files.length === 0) return;
const formData = new FormData();
formData.append('image', files[0]);
uploadProfileImageFile(formData);

const file = files[0];
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
if (img.width < 117 || img.height < 156) {
alert('사진 크기가 너무 작습니다.');
return;
}

if (img.width > 117 || img.height > 156) {
setImageSrc(URL.createObjectURL(file));
setIsModalOpen(true);
} else {
const formData = new FormData();
formData.append('image', file, 'image.jpg');
uploadProfileImageFile(formData);
}
};
};

const onDragEnter = (e: DragEvent<HTMLDivElement>) => {
Expand All @@ -71,9 +90,24 @@ const ProfileUploader = ({
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const formData = new FormData();
formData.append('image', e.dataTransfer.files[0]);
uploadProfileImageFile(formData);
const file = e.dataTransfer.files[0];
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
if (img.width < 117 || img.height < 156) {
alert('사진 크기가 너무 작습니다.');
return;
}

if (img.width > 117 || img.height > 156) {
setImageSrc(URL.createObjectURL(file));
setIsModalOpen(true);
} else {
const formData = new FormData();
formData.append('image', file, 'image.jpg');
uploadProfileImageFile(formData);
}
};
setIsDragging(false);
};

Expand Down Expand Up @@ -127,6 +161,19 @@ const ProfileUploader = ({
onChange={handleImageFileChange}
hidden
/>
{imageSrc && (
<CropImageModal
isOpen={isModalOpen}
imageSrc={imageSrc}
onClose={() => setIsModalOpen(false)}
onCropComplete={(croppedImage) => {
const formData = new FormData();
formData.append('image', croppedImage, 'image.png, image.jpg, image.jpeg');
uploadProfileImageFile(formData);
}}
zoom={1}
/>
)}
</StyledProfileUploader>
);
};
Expand Down
1 change: 1 addition & 0 deletions apps/user/src/components/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export { default as GradePreview } from './GradePreview/GradePreview';
export { default as PdfGeneratedLoader } from './PdfGeneratedLoader/PdfGeneratedLoader';
export { default as ProfileUploader } from './ProfileUploader/ProfileUploader';
export { default as ProgressSteps } from './ProgressSteps/ProgressSteps';
export { default as CropImageModal } from './CropImageModal/CropImageModal';
68 changes: 68 additions & 0 deletions apps/user/src/utils/cropImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
const createImage = (url: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', (error) => reject(error));
image.setAttribute('crossOrigin', 'anonymous');
image.src = url;
});

interface PixelCrop {
x: number;
y: number;
width: number;
height: number;
}

export const getCropImg = async (
imageSrc: string,
pixelCrop: PixelCrop,
outputWidth = 117,
outputHeight = 156
) => {
const image = await createImage(imageSrc);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

canvas.width = outputWidth * 2;
canvas.height = outputHeight * 2;

if (!ctx) {
throw new Error('Canvas 2D context not available');
}

ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';

ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
outputWidth * 2,
outputHeight * 2
);

const downscaledCanvas = document.createElement('canvas');
const downscaledCtx = downscaledCanvas.getContext('2d');

downscaledCanvas.width = outputWidth;
downscaledCanvas.height = outputHeight;

if (!downscaledCtx) {
throw new Error('Canvas 2D context not available');
}

downscaledCtx.drawImage(canvas, 0, 0, outputWidth, outputHeight);

return new Promise((resolve) => {
downscaledCanvas.toBlob((blob) => {
if (blob) {
resolve(URL.createObjectURL(blob));
}
}, 'image/jpeg');
});
};
1 change: 1 addition & 0 deletions apps/user/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { default as updateSlicedSubjectList } from './updateSlicedSubjectList';
export { default as formatApplicationDate } from './formatApplicationDate';
export { default as formatStartDate } from './formatStartDate';
export { formatStatus } from './formatStatus';
export { getCropImg } from './cropImage';
Loading

0 comments on commit 6176002

Please sign in to comment.