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(); + }); });