diff --git a/src/components/common/ImageCropDialog.tsx b/src/components/common/ImageCropDialog.tsx
index 7640bf5..2efd881 100644
--- a/src/components/common/ImageCropDialog.tsx
+++ b/src/components/common/ImageCropDialog.tsx
@@ -65,13 +65,28 @@ function toSourceCrop(crop: PixelCrop, image: HTMLImageElement) {
const scaleX = image.naturalWidth / renderedWidth;
const scaleY = image.naturalHeight / renderedHeight;
+ const sourceLeft = Math.round(crop.x * scaleX);
+ const sourceTop = Math.round(crop.y * scaleY);
+ const sourceRight = Math.round((crop.x + crop.width) * scaleX);
+ const sourceBottom = Math.round((crop.y + crop.height) * scaleY);
+ const sourceCoordinates = [sourceLeft, sourceTop, sourceRight, sourceBottom];
- return {
- x: Math.round(crop.x * scaleX),
- y: Math.round(crop.y * scaleY),
- width: Math.round(crop.width * scaleX),
- height: Math.round(crop.height * scaleY),
- };
+ if (sourceCoordinates.some((value) => !Number.isFinite(value))) return null;
+
+ const x = clamp(sourceLeft, 0, image.naturalWidth);
+ const y = clamp(sourceTop, 0, image.naturalHeight);
+ const right = clamp(sourceRight, 0, image.naturalWidth);
+ const bottom = clamp(sourceBottom, 0, image.naturalHeight);
+ const width = right - x;
+ const height = bottom - y;
+
+ if (width <= 0 || height <= 0) return null;
+
+ return { x, y, width, height };
+}
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), max);
}
export function ImageCropDialog({
@@ -135,7 +150,7 @@ export function ImageCropDialog({
const sourceCrop = toSourceCrop(cropPixels, image);
if (!sourceCrop) {
- setErrorMessage('이미지 크기를 확인하지 못했습니다. 이미지를 다시 선택해주세요.');
+ setErrorMessage('크롭 영역이 이미지 범위를 벗어났습니다. 영역을 다시 조절해주세요.');
return;
}
diff --git a/src/components/common/__tests__/ImageCropDialog.test.tsx b/src/components/common/__tests__/ImageCropDialog.test.tsx
index d6c9c54..9dfa6b9 100644
--- a/src/components/common/__tests__/ImageCropDialog.test.tsx
+++ b/src/components/common/__tests__/ImageCropDialog.test.tsx
@@ -29,6 +29,24 @@ vi.mock('react-image-crop', () => ({
>
크롭 영역 변경
+
+
+
{children}
{renderSelectionAddon?.()}
@@ -69,7 +87,7 @@ describe('ImageCropDialog', () => {
const preparedImage = {
file: new File(['output'], 'region.webp', { type: 'image/webp' }),
metadata: {
- original: { width: 1000, height: 500, byteSize: 1_000_000, mimeType: 'image/jpeg' },
+ original: { width: 1000, height: 750, byteSize: 1_000_000, mimeType: 'image/jpeg' },
output: {
width: 1000,
height: 500,
@@ -98,7 +116,7 @@ describe('ImageCropDialog', () => {
width: { configurable: true, value: 500 },
height: { configurable: true, value: 250 },
naturalWidth: { configurable: true, value: 1000 },
- naturalHeight: { configurable: true, value: 500 },
+ naturalHeight: { configurable: true, value: 750 },
});
fireEvent.load(image);
@@ -106,17 +124,17 @@ describe('ImageCropDialog', () => {
await user.selectOptions(screen.getByLabelText('크롭 비율'), 'free');
expect(screen.getByTestId('crop-editor')).toHaveAttribute('data-aspect', 'free');
- expect(await screen.findByLabelText('원본 기준 크롭 900 × 450px')).toBeInTheDocument();
+ expect(await screen.findByLabelText('원본 기준 크롭 900 × 675px')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '크롭 영역 변경' }));
- expect(await screen.findByLabelText('원본 기준 크롭 600 × 300px')).toBeInTheDocument();
+ expect(await screen.findByLabelText('원본 기준 크롭 600 × 450px')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '크롭 적용' }));
await waitFor(() => {
expect(preprocessImageToWebP).toHaveBeenCalledWith(
file,
expect.objectContaining({
- crop: { x: 20, y: 40, width: 600, height: 300 },
+ crop: { x: 20, y: 60, width: 600, height: 450 },
quality: 70,
})
);
@@ -124,4 +142,106 @@ describe('ImageCropDialog', () => {
expect(onPrepared).toHaveBeenCalledWith(preparedImage);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
+
+ it('음수 시작 좌표의 크롭은 원본과 겹치는 부분만 전처리에 전달한다', async () => {
+ const user = userEvent.setup();
+ const file = new File(['source'], 'region.jpg', { type: 'image/jpeg' });
+ preprocessImageToWebP.mockResolvedValue({ file, metadata: {} });
+
+ render(
+
+ );
+
+ const image = await screen.findByAltText('크롭할 원본 이미지');
+ Object.defineProperties(image, {
+ width: { configurable: true, value: 500 },
+ height: { configurable: true, value: 250 },
+ naturalWidth: { configurable: true, value: 1000 },
+ naturalHeight: { configurable: true, value: 500 },
+ });
+ fireEvent.load(image);
+
+ await user.click(screen.getByRole('button', { name: '음수 좌표 크롭 영역 변경' }));
+ await user.click(screen.getByRole('button', { name: '크롭 적용' }));
+
+ await waitFor(() => {
+ expect(preprocessImageToWebP).toHaveBeenCalledWith(
+ file,
+ expect.objectContaining({ crop: { x: 0, y: 0, width: 100, height: 180 } })
+ );
+ });
+ });
+
+ it('원본 이미지 경계를 넘는 크롭은 원본 범위로 잘라 WebP 전처리에 전달한다', async () => {
+ const user = userEvent.setup();
+ const file = new File(['source'], 'region.jpg', { type: 'image/jpeg' });
+ const onPrepared = vi.fn();
+ preprocessImageToWebP.mockResolvedValue({ file, metadata: {} });
+
+ render(
+
+ );
+
+ const image = await screen.findByAltText('크롭할 원본 이미지');
+ Object.defineProperties(image, {
+ width: { configurable: true, value: 500 },
+ height: { configurable: true, value: 250 },
+ naturalWidth: { configurable: true, value: 1000 },
+ naturalHeight: { configurable: true, value: 500 },
+ });
+ fireEvent.load(image);
+
+ await user.click(screen.getByRole('button', { name: '경계 밖 크롭 영역 변경' }));
+ await user.click(screen.getByRole('button', { name: '크롭 적용' }));
+
+ await waitFor(() => {
+ expect(preprocessImageToWebP).toHaveBeenCalledWith(
+ file,
+ expect.objectContaining({ crop: { x: 900, y: 460, width: 100, height: 40 } })
+ );
+ });
+ expect(onPrepared).toHaveBeenCalledTimes(1);
+ });
+
+ it('원본과 겹치지 않는 빈 크롭은 전처리하지 않고 조절 오류를 표시한다', async () => {
+ const user = userEvent.setup();
+ const file = new File(['source'], 'region.jpg', { type: 'image/jpeg' });
+
+ render(
+
+ );
+
+ const image = await screen.findByAltText('크롭할 원본 이미지');
+ Object.defineProperties(image, {
+ width: { configurable: true, value: 500 },
+ height: { configurable: true, value: 250 },
+ naturalWidth: { configurable: true, value: 1000 },
+ naturalHeight: { configurable: true, value: 500 },
+ });
+ fireEvent.load(image);
+
+ await user.click(screen.getByRole('button', { name: '빈 크롭 영역 변경' }));
+ await user.click(screen.getByRole('button', { name: '크롭 적용' }));
+
+ expect(await screen.findByText('크롭 영역이 이미지 범위를 벗어났습니다. 영역을 다시 조절해주세요.')).toBeInTheDocument();
+ expect(preprocessImageToWebP).not.toHaveBeenCalled();
+ });
});