From a15f84cbf03422e7d6c7d699db890359669e366b Mon Sep 17 00:00:00 2001 From: "bottlenote-app[bot]" <289617182+bottlenote-app[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:41:23 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A7=80=EC=97=AD=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=81=AC=EB=A1=AD=20=EA=B2=BD=EA=B3=84=EA=B0=92=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/ImageCropDialog.tsx | 29 +++- .../common/__tests__/ImageCropDialog.test.tsx | 130 +++++++++++++++++- 2 files changed, 147 insertions(+), 12 deletions(-) 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(); + }); });