diff --git a/DESIGN.md b/DESIGN.md index 39edbe5..6c49330 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -170,10 +170,10 @@ Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system ### ImageCropDialog -- Structure: modal crop surface with preview, output size, directional move buttons, zoom range, reset, cancel, and apply. -- Variants: caller injects `aspect`, `maxWidth`, and `noUpscale`; domain profile names and GIF exceptions stay outside the primitive. -- Accessibility: dialog has visible title, keyboard preview controls, range input, and button alternatives. No pointer-only requirement in Phase 1.6. -- Motion: transform-only preview adjustment. No crop dependency is added; Canvas is used only when generating the final `File`. +- Structure: `react-advanced-cropper` viewport, expected result dimensions, and native reset, cancel, and apply actions form the modal crop surface; pending and error states remain visible while the result is prepared or cannot be generated. +- Contract: the caller continues to supply the external `aspect`, `maxWidth`, and `noUpscale` policies; `aspect: 'free'` preserves the source image ratio, and apply produces the final `File` through the existing `renderCrop` seam. Domain profile names and GIF exceptions stay outside the primitive. +- Accessibility: the dialog has a visible title and focus trap, Escape cancels it, and the viewport supports keyboard movement and zoom as well as pointer input. Reset, cancel, and apply are native button targets of at least 44px, so cropping is never pointer-only. +- Motion: the cropper library owns interaction transitions inside its viewport; the surrounding dialog adds no decorative motion. ### UploadProgress diff --git a/docs/20260725_AI캐릭터관리자웹/plan-task.md b/docs/20260725_AI캐릭터관리자웹/plan-task.md index 3ce3b3e..5843455 100644 --- a/docs/20260725_AI캐릭터관리자웹/plan-task.md +++ b/docs/20260725_AI캐릭터관리자웹/plan-task.md @@ -6031,6 +6031,15 @@ critical·serious 0건과 mock/server 상태 분리를 확인한다. E2E용 ADMI - 판정: 마지막 marker 문자열을 첫 동일 occurrence로 다시 찾는 `REV-P10-018` Low를 확정해 신규 `P10-R17`로 전환했다. 나머지 finding/checklist/H2/top-tail 동기화에는 신규 문제가 없었다. - 남은 항목: `P10-R17`, 실제 crop pixel 비교, stale ADMIN server 확인, 실제 개발 API Series/FanTalk/Comments/file policy 수동 QA. Chromium/mobile Chrome 지원 범위만 유지한다. +**ImageCropDialog advanced cropper 전환 검증 기록 — 2026-08-04:** + +- 무엇을: 공통 `ImageCropDialog`의 custom 이동·zoom UI를 `react-advanced-cropper@0.20.1`로 교체하고, 기존 `aspect`, `maxWidth`, `noUpscale`, `renderCrop` 계약과 pending/error/single-flight 처리를 유지했다. 사용자의 명시적 결정에 따라 별도 방향·zoom 버튼은 제거하고 pointer와 keyboard 조작만 유지하도록 PRD `FILE-008`과 UI 원칙을 정렬했다. +- TDD/회귀: viewport·aspect·keyboard·null coordinates·orientation request·focus wrapper를 각각 RED/GREEN으로 확인했다. 독립 리뷰가 제기한 비중앙 좌표 이중 축척 후보는 `scalePreviewOffset`이 원본 크기가 아니라 `baseWidth`/`baseHeight`를 사용함을 확인했고, off-center 1:1과 210:297 좌표를 `calculateCropSourceRect`로 되돌리는 회귀 2건을 추가해 선택 source rect가 일치함을 검증했다. 적용 중 Escape가 disabled 취소 버튼을 우회하는 RED도 재현해 pending 동안 Escape를 무시하도록 정렬했다. +- 자동 검증: `npm run test:run`은 82 files / 438 tests passed, `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, `git diff --check`는 exit 0 또는 no output이었다. dev/prod build는 309 modules, JS 597.35kB(gzip 158.54~158.55kB)와 기존 500kB chunk warning을 기록했다. +- 브라우저 QA: `npm run e2e:mock -- tests/e2e/series.spec.ts --project=chromium`은 7 passed였다. mock UI 768/1280px에서 pointer drag, 방향키, `+`/`-`, 초기화, 취소/Escape, focus trap, 적용과 가로 overflow 0을 확인했고, 2400×1804 입력은 210:297 비율의 1000×1414px PNG로 생성됐다. 375px Series 생성은 기존 모바일 read-only 정책에 따라 crop form을 노출하지 않는다. +- 공급망: `npm audit --omit=dev`는 vulnerability 0건이다. 전체 `npm audit`의 high 1건은 ESLint가 사용하는 dev-only `brace-expansion@5.0.8` 경로이며 production cropper dependency에는 포함되지 않는다. +- 남은 항목: 실제 개발 API 파일 업로드 수동 QA는 기존 server integration 대기로 유지한다. 375px Series 안내의 기존 오탈자·줄바꿈은 crop surface 밖의 별도 UI 정리 범위다. + **P10-R17 수정 검증 기록 — 2026-08-01:** - 무엇을: 동일 제목·날짜 Progress marker가 반복돼도 마지막 occurrence의 record를 선택하도록 보완했다. diff --git a/docs/20260725_AI캐릭터관리자웹/prd.md b/docs/20260725_AI캐릭터관리자웹/prd.md index d46bb32..d0a5c69 100644 --- a/docs/20260725_AI캐릭터관리자웹/prd.md +++ b/docs/20260725_AI캐릭터관리자웹/prd.md @@ -356,11 +356,11 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘 | FILE-001 | 확정 | 캐릭터·오디오 cover·시리즈·커뮤니티 image의 최대 크기는 `10,485,760 bytes` 이하다. `10,485,761 bytes`부터 거부한다. | | FILE-002 | 확정 | 기본 image 형식은 JPEG(`.jpg`/`.jpeg`, `image/jpeg`)와 PNG(`.png`, `image/png`)다. WebP 등 다른 형식은 허용하지 않는다. | | FILE-003 | 확정 | GIF(`.gif`, `image/gif`)는 커뮤니티 image에서만 허용한다. 캐릭터·시리즈·오디오 cover에서는 거부한다. | -| FILE-004 | 확정 | 커뮤니티 JPEG/PNG image는 자유 aspect ratio로 크롭하며 결과의 최대 가로 폭은 800px, 세로는 선택한 crop ratio에 따라 결정한다. | +| FILE-004 | 확정 | 커뮤니티 JPEG/PNG image는 원본 aspect ratio를 유지해 크롭하며 결과의 최대 가로 폭은 800px, 세로는 원본 ratio에 따라 결정한다. | | FILE-005 | 확정 | 시리즈 image는 `210:297` 세로형 고정 aspect ratio로 크롭하며 결과의 최대 가로 폭은 1,000px다. | | FILE-006 | 확정 | 오디오 콘텐츠 cover는 `1:1` 고정 aspect ratio로 크롭하며 결과의 최대 가로 폭은 800px다. | | FILE-007 | 확정 | 커뮤니티 JPEG/PNG·시리즈·오디오 콘텐츠에서 새 image를 선택하면 업로드 전에 crop UI를 반드시 거친다. 커뮤니티 GIF는 예외다. | -| FILE-008 | 확정 | crop UI는 이동, 확대/축소, 초기화, 결과 미리보기, 취소, 적용을 제공한다. drag/pinch만 강제하지 않고 키보드와 버튼 대안을 제공한다. | +| FILE-008 | 확정 | crop UI는 pointer drag/pinch와 keyboard 방향키·확대/축소, 초기화, 결과 미리보기, 취소, 적용을 제공한다. cropper가 직접 조작을 제공하므로 별도 방향·zoom 버튼은 두지 않는다. | | FILE-009 | 확정 | optional 교체 파일 미전송은 기존 media 유지다. crop 취소도 기존 media를 변경하지 않는다. 기존 media 자체 제거는 별도 remove contract가 없어 범위 밖이다. | | FILE-010 | 확정 | 캐릭터 image는 JPEG/PNG만 허용하고 `1:1` 고정 aspect ratio로 크롭하며 결과의 최대 가로·세로는 800px다. | | FILE-011 | 확정 | 커뮤니티 GIF는 crop하지 않는다. crop Dialog를 열지 않고 원본 비율과 animation을 유지한 File을 등록한다. | @@ -524,7 +524,7 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘 - 비활성화처럼 영향이 큰 동작은 Switch가 아니라 AlertDialog를 사용한다. - icon-only 버튼에는 `aria-label`과 Tooltip을 제공한다. - 모바일의 보조 작업은 DropdownMenu 또는 Drawer에 배치하되 핵심 답변·댓글 동작은 한 번에 찾을 수 있어야 한다. -- crop Dialog는 pointer drag와 pinch/zoom을 지원하되 이동·확대·축소·초기화를 실행하는 명시적 버튼과 keyboard 조작도 제공한다. +- crop Dialog는 pointer drag와 pinch/zoom, 방향키와 `+`/`-` keyboard 조작, 초기화를 제공하며 별도 방향·zoom 버튼은 두지 않는다. - crop frame, preview, 적용/취소 control은 tablet touch target 44×44px 이상과 보이는 label 또는 accessible name을 가진다. ### 10.5 화면 상태 diff --git a/package-lock.json b/package-lock.json index dec4a1c..de7d7ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.4", "react": "19.2.8", + "react-advanced-cropper": "0.20.1", "react-dom": "19.2.8", "zod": "^4.4.3" }, @@ -2407,6 +2408,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/advanced-cropper": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/advanced-cropper/-/advanced-cropper-0.17.1.tgz", + "integrity": "sha512-Z1P0sYOXa2tqZjeY742QtNERofXh1AuOa27LEurO9rbx3IfzLrGQlzy7sWEc5VN9hRg+J/qCiMmnB6tUDLb1TA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2588,6 +2602,12 @@ "node": ">=18" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -4186,6 +4206,24 @@ "node": ">=0.10.0" } }, + "node_modules/react-advanced-cropper": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/react-advanced-cropper/-/react-advanced-cropper-0.20.1.tgz", + "integrity": "sha512-Pcmkv0xQMpig6+LkM+zLbEuqBbYG3+CwXvIfYU+LDNn9l8t91Jm0fp9MSTNW0pjIvT6frAGTfmlnvnZW4PEs7Q==", + "license": "MIT", + "dependencies": { + "advanced-cropper": "~0.17.1", + "classnames": "^2.2.6", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", @@ -4589,9 +4627,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/package.json b/package.json index 90f22f3..b1830b5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.4", "react": "19.2.8", + "react-advanced-cropper": "0.20.1", "react-dom": "19.2.8", "zod": "^4.4.3" }, diff --git a/src/shared/ui/__tests__/image-crop-dialog.test.tsx b/src/shared/ui/__tests__/image-crop-dialog.test.tsx index 0437b68..c37d692 100644 --- a/src/shared/ui/__tests__/image-crop-dialog.test.tsx +++ b/src/shared/ui/__tests__/image-crop-dialog.test.tsx @@ -1,197 +1,233 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { expect, test, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, test, vi } from "vitest"; -import { ImageCropDialog } from "@/shared/ui/image-crop-dialog"; +import { calculateCropSourceRect } from "@/shared/lib/crop-image"; import type { CropRenderRequest } from "@/shared/lib/crop-image"; +import { ImageCropDialog } from "@/shared/ui/image-crop-dialog"; +import type { ImageCropDialogProps } from "@/shared/ui/image-crop-dialog"; + +type FakeCropperCoordinates = { + readonly height: number; + readonly left: number; + readonly top: number; + readonly width: number; +}; + +type FakeCropperProps = { + readonly checkOrientation?: boolean; + readonly src?: string; + readonly stencilProps?: { + readonly aspectRatio?: number; + }; +}; + +type FakeCropperHandle = { + readonly getCoordinates: () => FakeCropperCoordinates | null; + readonly moveImage: (left: number, top?: number) => void; + readonly reset: () => void; + readonly zoomImage: (scale: number) => void; +}; + +const cropperFake = vi.hoisted(() => { + let checkOrientation: boolean | undefined; + + return { + get checkOrientation() { + return checkOrientation; + }, + getCoordinates: vi.fn<() => FakeCropperCoordinates | null>(() => ({ height: 600, left: 300, top: 0, width: 600 })), + moveImage: vi.fn(), + reset: vi.fn(), + set checkOrientation(value: boolean | undefined) { + checkOrientation = value; + }, + src: "", + stencilAspectRatio: 0, + zoomImage: vi.fn(), + }; +}); + +vi.mock("react-advanced-cropper", async () => { + const { forwardRef, useImperativeHandle } = await vi.importActual("react"); + + return { + Cropper: forwardRef(function FakeCropper(props, ref) { + cropperFake.checkOrientation = props.checkOrientation; + cropperFake.src = props.src ?? ""; + cropperFake.stencilAspectRatio = props.stencilProps?.aspectRatio ?? 0; + useImperativeHandle(ref, () => ({ + getCoordinates: cropperFake.getCoordinates, + moveImage: cropperFake.moveImage, + reset: cropperFake.reset, + zoomImage: cropperFake.zoomImage, + })); + + return
; + }), + ImageRestriction: { + fillArea: "fillArea", + fitArea: "fitArea", + none: "none", + stencil: "stencil", + }, + }; +}); const image = { file: new File(["image"], "profile.png", { type: "image/png" }), height: 600, previewUrl: "blob:profile", - width: 600, + width: 1200, }; -function setPreviewFrameSize(width: number, height: number): void { - Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", { - configurable: true, - value: () => ({ bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) }), - }); +const squarePolicy = { aspect: 1, maxWidth: 800, noUpscale: true } as const; + +function renderDialog(props: ImageCropDialogProps): void { + render(); } -function rect(width: number, height: number): DOMRect { - return { bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) }; -} +beforeEach(() => { + vi.clearAllMocks(); + cropperFake.checkOrientation = undefined; + cropperFake.getCoordinates.mockReturnValue({ height: 600, left: 300, top: 0, width: 600 }); + cropperFake.src = ""; + cropperFake.stencilAspectRatio = 0; +}); -async function withElementRects(testBody: () => Promise): Promise { - const originalElementRect = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "getBoundingClientRect"); - const originalImageRect = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "getBoundingClientRect"); +test("Given an open dialog, when it renders, then it shows the Cropper viewport without legacy direction or range controls", () => { + renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy }); - Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", { - configurable: true, - value(this: HTMLElement) { - if (this.getAttribute("aria-label") === "이미지 crop viewport") { - return rect(181, 256); - } + expect(screen.getByRole("application", { name: "이미지 crop viewport" })).toHaveAttribute("tabindex", "0"); + expect(cropperFake.src).toBe(image.previewUrl); + expect(screen.queryByRole("button", { name: "위로 이동" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "아래로 이동" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "왼쪽으로 이동" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "오른쪽으로 이동" })).not.toBeInTheDocument(); + expect(screen.queryByRole("slider", { name: "확대 비율" })).not.toBeInTheDocument(); +}); - return rect(0, 0); - }, - }); - Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", { - configurable: true, - value: () => rect(384, 384), - }); +test("Given a local preview URL, when the Cropper renders, then it disables the library orientation request", () => { + renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy }); - return testBody().finally(() => { - if (originalElementRect === undefined) { - Reflect.deleteProperty(HTMLElement.prototype, "getBoundingClientRect"); - } else { - Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", originalElementRect); - } + expect(cropperFake.checkOrientation).toBe(false); +}); - if (originalImageRect === undefined) { - Reflect.deleteProperty(HTMLImageElement.prototype, "getBoundingClientRect"); - } else { - Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", originalImageRect); - } - }); -} - -test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply controls", async () => { +test("Given Cropper coordinates, when apply is selected, then it maps the existing render request to the same source rectangle and returns its File", async () => { + const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" }); const onApply = vi.fn(); - const onCancel = vi.fn(); - const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" }))); + const renderCrop = vi.fn((request: CropRenderRequest) => { + expect(calculateCropSourceRect(request)).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 }); + return Promise.resolve(croppedFile); + }); + renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop }); - render(); + fireEvent.click(screen.getByRole("button", { name: "적용" })); - fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" })); - fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } }); - expect(screen.getByText("예상 결과 400 × 400px")).toBeInTheDocument(); + await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile)); + expect(renderCrop).toHaveBeenCalledTimes(1); +}); +test.each([ + { + coordinates: { height: 300, left: 400, top: 100, width: 300 }, + expected: { height: 300, sourceX: 400, sourceY: 100, width: 300 }, + policy: squarePolicy, + }, + { + coordinates: { height: 1500, left: 1000, top: 500, width: 1060.5 }, + expected: { height: 1500, sourceX: 1000, sourceY: 500, width: 1061 }, + policy: { aspect: 210 / 297, maxWidth: 1000, noUpscale: true } as const, + }, +])("Given an off-center fixed-ratio crop, when apply is selected, then it preserves the Cropper source coordinates", async ({ coordinates, expected, policy }) => { + const sourceImage = policy.aspect === 1 ? image : { ...image, height: 3000, width: 4000 }; + const renderCrop = vi.fn<(request: CropRenderRequest) => Promise>(() => Promise.resolve(new File(["crop"], "profile-crop.png", { type: "image/png" }))); + cropperFake.getCoordinates.mockReturnValue(coordinates); + renderDialog({ image: sourceImage, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy, renderCrop }); + + fireEvent.click(screen.getByRole("button", { name: "적용" })); + + await waitFor(() => expect(renderCrop).toHaveBeenCalledTimes(1)); + expect(calculateCropSourceRect(renderCrop.mock.calls[0]![0])).toEqual(expected); +}); + +test("Given Cropper coordinates are not ready, when apply is selected, then it preserves the centered crop contract", async () => { + const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" }); + const onApply = vi.fn(); + const renderCrop = vi.fn((request: CropRenderRequest) => { + expect(calculateCropSourceRect(request)).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 }); + return Promise.resolve(croppedFile); + }); + cropperFake.getCoordinates.mockReturnValue(null); + renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop }); + + fireEvent.click(screen.getByRole("button", { name: "적용" })); + + await waitFor(() => expect(renderCrop).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile)); +}); + +test("Given a free aspect policy, when the dialog renders, then it gives the source ratio to the stencil and reports an 800 by 400 result", () => { + cropperFake.getCoordinates.mockReturnValue({ height: 600, left: 0, top: 0, width: 1200 }); + renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: { aspect: "free", maxWidth: 800, noUpscale: true } }); + + expect(cropperFake.stencilAspectRatio).toBe(2); + expect(screen.getByText("예상 결과 800 × 400px")).toBeInTheDocument(); +}); + +test("Given the focusable Cropper viewport, when keyboard controls and reset are used, then it delegates movement, zoom, and reset to CropperRef", () => { + renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy }); + const viewport = screen.getByTestId("advanced-cropper"); + + viewport.focus(); + fireEvent.keyDown(viewport, { key: "ArrowUp" }); + fireEvent.keyDown(viewport, { key: "ArrowDown" }); + fireEvent.keyDown(viewport, { key: "ArrowLeft" }); + fireEvent.keyDown(viewport, { key: "ArrowRight" }); + fireEvent.keyDown(viewport, { key: "+" }); + fireEvent.keyDown(viewport, { key: "=" }); + fireEvent.keyDown(viewport, { key: "-" }); fireEvent.click(screen.getByRole("button", { name: "초기화" })); - fireEvent.click(screen.getByRole("button", { name: "적용" })); - await screen.findByText("예상 결과 600 × 600px"); - expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 0, offsetY: 0, outputHeight: 600, outputWidth: 600, zoom: 1 })); - expect(onApply).toHaveBeenCalledWith(expect.any(File)); - fireEvent.click(screen.getByRole("button", { name: "취소" })); - expect(onCancel).toHaveBeenCalled(); + expect(cropperFake.moveImage).toHaveBeenNthCalledWith(1, 0, -10); + expect(cropperFake.moveImage).toHaveBeenNthCalledWith(2, 0, 10); + expect(cropperFake.moveImage).toHaveBeenNthCalledWith(3, -10, 0); + expect(cropperFake.moveImage).toHaveBeenNthCalledWith(4, 10, 0); + expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(1, 1.1); + expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(2, 1.1); + expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(3, 0.9); + expect(cropperFake.reset).toHaveBeenCalledTimes(1); }); -test("ImageCropDialog supports keyboard movement and no-upscale sizing", async () => { - const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" }))); - - render(); - - const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" }); - fireEvent.keyDown(preview, { key: "ArrowRight" }); - fireEvent.keyDown(preview, { key: "+" }); - fireEvent.click(screen.getByRole("button", { name: "적용" })); - - expect(await screen.findByText("예상 결과 545 × 272px")).toBeInTheDocument(); - expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 272, outputWidth: 545, zoom: 1.1 })); -}); - -test("ImageCropDialog sends preview frame dimensions with crop offsets", async () => { - setPreviewFrameSize(256, 256); - const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" }))); - - render(); - - fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" })); - fireEvent.click(screen.getByRole("button", { name: "적용" })); - - await screen.findByText("예상 결과 1000 × 1000px"); - expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, previewFrameHeight: 256, previewFrameWidth: 256, sourceHeight: 3000, sourceWidth: 4000 })); -}); - -test("ImageCropDialog measures the visible crop viewport instead of the transformed image", async () => { - await withElementRects(async () => { - const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" }))); - - render(); - - fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" })); - fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } }); - fireEvent.click(screen.getByRole("button", { name: "적용" })); - - await screen.findByText("예상 결과 1000 × 1414px"); - expect(screen.getByLabelText("이미지 crop viewport")).toHaveStyle({ aspectRatio: `${210 / 297}` }); - expect(screen.getByAltText("선택한 이미지 미리보기")).toHaveClass("h-full", "w-auto", "max-w-none"); - expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ previewFrameHeight: 256, previewFrameWidth: 181, zoom: 1.5 })); - }); -}); - -test("ImageCropDialog clamps movement on axes without crop overhang", async () => { - await withElementRects(async () => { - const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetY}`], "crop.png", { type: "image/png" }))); - - render(); - - fireEvent.click(screen.getByRole("button", { name: "아래로 이동" })); - fireEvent.click(screen.getByRole("button", { name: "적용" })); - - expect(screen.getByAltText("선택한 이미지 미리보기")).toHaveStyle({ transform: "translate(-50%, -50%) translate(0px, 0px) scale(1)" }); - expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetY: 0 })); - }); -}); - -test("ImageCropDialog keeps apply single-flight and allows retry after render failure", async () => { +test("Given an apply request in flight, when apply is repeated and rendering fails, then it stays single-flight, shows the error, and allows retry", async () => { const onApply = vi.fn(); let rejectCrop: (error: Error) => void = () => undefined; const renderCrop = vi.fn(() => new Promise((_resolve, reject) => { rejectCrop = reject; })); - - render(); + renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop }); fireEvent.click(screen.getByRole("button", { name: "적용" })); fireEvent.click(screen.getByRole("button", { name: "적용" })); + await screen.findByRole("status"); expect(renderCrop).toHaveBeenCalledTimes(1); - rejectCrop(new Error("render failed")); expect(await screen.findByRole("alert")).toHaveTextContent("이미지 crop을 적용하지 못했습니다."); - fireEvent.click(screen.getByRole("button", { name: "적용" })); expect(renderCrop).toHaveBeenCalledTimes(2); expect(onApply).not.toHaveBeenCalled(); }); -test("ImageCropDialog changes zoom with a two pointer pinch", async () => { - const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" }))); - - render(); - - const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" }); - fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 }); - fireEvent.pointerDown(preview, { clientX: 200, clientY: 100, pointerId: 2 }); - fireEvent.pointerMove(preview, { clientX: 250, clientY: 100, pointerId: 2 }); - fireEvent.pointerUp(preview, { pointerId: 1 }); - fireEvent.pointerUp(preview, { pointerId: 2 }); - fireEvent.click(screen.getByRole("button", { name: "적용" })); - - expect(await screen.findByText("예상 결과 400 × 400px")).toBeInTheDocument(); - expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ outputHeight: 400, outputWidth: 400, zoom: 1.5 })); - expect(screen.getByRole("slider", { name: "확대 비율" })).toHaveValue("1.5"); -}); - -test("ImageCropDialog disables native touch gestures on the crop preview", () => { - render(); - - expect(screen.getByRole("application", { name: "이미지 crop 미리보기" })).toHaveStyle({ touchAction: "none" }); -}); - -test("ImageCropDialog closes itself on Escape without bubbling to parent dialogs", () => { +test("Given an open dialog inside a parent, when Escape is pressed, then it cancels once without bubbling", () => { const onCancel = vi.fn(); const onParentEscape = vi.fn(); - render(
{ if (event.key === "Escape") { onParentEscape(); } }}> - +
, ); @@ -201,24 +237,27 @@ test("ImageCropDialog closes itself on Escape without bubbling to parent dialogs expect(onParentEscape).not.toHaveBeenCalled(); }); -test("ImageCropDialog supports free ratio output and pointer drag movement", async () => { - const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX},${request.offsetY}`], "crop.png", { type: "image/png" }))); +test("Given crop rendering is in progress, when Escape is pressed, then it keeps the pending result active", async () => { + const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" }); + const onApply = vi.fn(); + const onCancel = vi.fn(); + let resolveCrop: (file: File) => void = () => undefined; + const renderCrop = vi.fn(() => new Promise((resolve) => { + resolveCrop = resolve; + })); + renderDialog({ image, onApply, onCancel, open: true, policy: squarePolicy, renderCrop }); - render(); - - const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" }); - fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } }); - fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 }); - fireEvent.pointerMove(preview, { clientX: 130, clientY: 115, pointerId: 1 }); - fireEvent.pointerUp(preview, { pointerId: 1 }); fireEvent.click(screen.getByRole("button", { name: "적용" })); + await screen.findByRole("status"); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); - expect(await screen.findByText("예상 결과 800 × 400px")).toBeInTheDocument(); - expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 30, offsetY: 15, outputHeight: 400, outputWidth: 800 })); + expect(onCancel).not.toHaveBeenCalled(); + resolveCrop(croppedFile); + await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile)); }); -test("ImageCropDialog renders nothing when closed", () => { - render(); +test("Given a closed dialog, when it renders, then it returns no dialog", () => { + renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: false, policy: squarePolicy }); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); diff --git a/src/shared/ui/image-crop-dialog.tsx b/src/shared/ui/image-crop-dialog.tsx index a79f61f..b9c2e14 100644 --- a/src/shared/ui/image-crop-dialog.tsx +++ b/src/shared/ui/image-crop-dialog.tsx @@ -1,4 +1,7 @@ -import { useCallback, useRef, useState } from "react"; +import { Cropper, ImageRestriction } from "react-advanced-cropper"; +import type { Coordinates, CropperRef } from "react-advanced-cropper"; +import "react-advanced-cropper/dist/style.css"; +import { useRef, useState } from "react"; import { calculateCropOutputSize, createCroppedImageFile } from "@/shared/lib/crop-image"; import type { CropRenderRequest } from "@/shared/lib/crop-image"; @@ -28,107 +31,42 @@ export type ImageCropDialogProps = { }; const MOVE_STEP = 10; -const ZOOM_STEP = 0.1; - -type PointerPoint = { - readonly x: number; - readonly y: number; -}; - -type PinchState = { - readonly distance: number; - readonly zoom: number; -}; - -type CropOffset = { - readonly x: number; - readonly y: number; -}; - -type CropFrameSize = { - readonly height: number; - readonly width: number; -}; export function ImageCropDialog({ image, onApply, onCancel, open, policy, renderCrop = createCroppedImageFile }: ImageCropDialogProps) { - const [offsetX, setOffsetX] = useState(0); - const [offsetY, setOffsetY] = useState(0); const [applyError, setApplyError] = useState(null); + const [coordinates, setCoordinates] = useState(null); const [isApplying, setIsApplying] = useState(false); - const [viewportSize, setViewportSize] = useState(null); - const [zoom, setZoom] = useState(1); - const dragPointRef = useRef<{ readonly x: number; readonly y: number } | null>(null); - const pinchRef = useRef(null); - const cropViewportRef = useRef(null); - const previewImageRef = useRef(null); - const pointersRef = useRef(new Map()); + const cropperRef = useRef(null); const { dialogRef, trapFocus } = useModalFocus(open); - const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width, zoom }); - const cropFrameAspect = policy.aspect === "free" ? image.width / image.height : policy.aspect; const sourceAspect = image.width / image.height; - const coverImageClass = sourceAspect > cropFrameAspect ? "h-full w-auto max-w-none" : "h-auto w-full max-w-none"; - const clampedOffset = clampOffset({ x: offsetX, y: offsetY }, viewportSize); - const setCropViewportNode = useCallback((node: HTMLDivElement | null) => { - cropViewportRef.current = node; - if (node === null) { - setViewportSize(null); - return; - } - - const rect = node.getBoundingClientRect(); - setViewportSize({ height: rect.height, width: rect.width }); - }, []); + const resolvedAspect = policy.aspect === "free" ? sourceAspect : policy.aspect; + const baseWidth = sourceAspect > resolvedAspect ? Math.round(image.height * resolvedAspect) : image.width; + const baseHeight = sourceAspect > resolvedAspect ? image.height : Math.round(image.width / resolvedAspect); + const zoom = coordinates === null ? 1 : baseWidth / coordinates.width; + const outputSize = calculateCropOutputSize({ + aspect: policy.aspect, + maxWidth: policy.maxWidth, + noUpscale: policy.noUpscale, + sourceHeight: image.height, + sourceWidth: image.width, + zoom, + }); if (!open) { return null; } - function resetCrop() { - setOffsetX(0); - setOffsetY(0); - setZoom(1); - } - - function move(deltaX: number, deltaY: number) { - setOffsetX((current) => current + deltaX); - setOffsetY((current) => current + deltaY); - } - - function changeZoom(nextZoom: number) { - setZoom(Math.min(3, Math.max(1, Number(nextZoom.toFixed(1))))); - } - - function clampOffset(offset: CropOffset, frameSize: CropFrameSize | null): CropOffset { - if (frameSize === null || frameSize.width <= 0 || frameSize.height <= 0) { - return offset; - } - - const coverWidthRatio = sourceAspect > cropFrameAspect ? sourceAspect / cropFrameAspect : 1; - const coverHeightRatio = sourceAspect > cropFrameAspect ? 1 : cropFrameAspect / sourceAspect; - const maxX = Math.max(0, (frameSize.width * coverWidthRatio * zoom - frameSize.width) / 2); - const maxY = Math.max(0, (frameSize.height * coverHeightRatio * zoom - frameSize.height) / 2); - - return { - x: Math.min(Math.max(offset.x, -maxX), maxX), - y: Math.min(Math.max(offset.y, -maxY), maxY), - }; - } - - function getPinchDistance() { - const points = Array.from(pointersRef.current.values()); - const first = points[0]; - const second = points[1]; - if (first === undefined || second === undefined) { - return null; - } - - return Math.hypot(second.x - first.x, second.y - first.y); + function updateCoordinates(cropper: CropperRef) { + setCoordinates(cropper.getCoordinates()); } function handleDialogKeyDown(event: React.KeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); + if (isApplying) { + return; + } onCancel(); return; } @@ -136,103 +74,88 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render trapFocus(event); } - function handlePreviewKeyDown(event: React.KeyboardEvent) { + function handleCropperKeyDown(event: React.KeyboardEvent) { + const cropper = cropperRef.current; + if (cropper === null) { + return; + } + switch (event.key) { case "ArrowDown": event.preventDefault(); - move(0, MOVE_STEP); + cropper.moveImage(0, MOVE_STEP); return; case "ArrowLeft": event.preventDefault(); - move(-MOVE_STEP, 0); + cropper.moveImage(-MOVE_STEP, 0); return; case "ArrowRight": event.preventDefault(); - move(MOVE_STEP, 0); + cropper.moveImage(MOVE_STEP, 0); return; case "ArrowUp": event.preventDefault(); - move(0, -MOVE_STEP); + cropper.moveImage(0, -MOVE_STEP); return; case "+": + case "=": event.preventDefault(); - changeZoom(zoom + ZOOM_STEP); + cropper.zoomImage(1.1); return; case "-": event.preventDefault(); - changeZoom(zoom - ZOOM_STEP); + cropper.zoomImage(0.9); return; default: } } - function startDrag(event: React.PointerEvent) { - pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); - event.currentTarget.setPointerCapture?.(event.pointerId); - if (pointersRef.current.size === 1) { - dragPointRef.current = { x: event.clientX, y: event.clientY }; - return; - } - - const distance = getPinchDistance(); - if (distance !== null) { - pinchRef.current = { distance, zoom }; - dragPointRef.current = null; - } - } - - function drag(event: React.PointerEvent) { - if (pointersRef.current.has(event.pointerId)) { - pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); - } - - const pinch = pinchRef.current; - const distance = getPinchDistance(); - if (pinch !== null && distance !== null) { - changeZoom(pinch.zoom * (distance / pinch.distance)); - return; - } - - const dragPoint = dragPointRef.current; - if (dragPoint === null) { - return; - } - - move(event.clientX - dragPoint.x, event.clientY - dragPoint.y); - dragPointRef.current = { x: event.clientX, y: event.clientY }; - } - - function stopDrag(event: React.PointerEvent) { - pointersRef.current.delete(event.pointerId); - event.currentTarget.releasePointerCapture?.(event.pointerId); - pinchRef.current = null; - dragPointRef.current = null; + function resetCrop() { + cropperRef.current?.reset(); } async function applyCrop() { if (isApplying) { return; } + + const freshCoordinates = cropperRef.current?.getCoordinates() ?? { + height: baseHeight, + left: (image.width - baseWidth) / 2, + top: (image.height - baseHeight) / 2, + width: baseWidth, + }; + + const requestZoom = baseWidth / freshCoordinates.width; + const renderedWidth = Math.round(baseWidth / requestZoom); + const renderedHeight = Math.round(baseHeight / requestZoom); + const centeredX = (image.width - renderedWidth) / 2; + const centeredY = (image.height - renderedHeight) / 2; + const requestOutputSize = calculateCropOutputSize({ + aspect: policy.aspect, + maxWidth: policy.maxWidth, + noUpscale: policy.noUpscale, + sourceHeight: image.height, + sourceWidth: image.width, + zoom: requestZoom, + }); setApplyError(null); setIsApplying(true); - const currentViewportRect = cropViewportRef.current?.getBoundingClientRect(); - const previewRect = previewImageRef.current?.getBoundingClientRect(); - const frameRect = currentViewportRect !== undefined && currentViewportRect.width > 0 && currentViewportRect.height > 0 ? currentViewportRect : previewRect; - const cropOffset = clampOffset({ x: offsetX, y: offsetY }, frameRect === undefined ? null : { height: frameRect.height, width: frameRect.width }); + try { const file = await renderCrop({ aspect: policy.aspect, file: image.file, - offsetX: cropOffset.x, - offsetY: cropOffset.y, - outputHeight: outputSize.height, - outputWidth: outputSize.width, - previewFrameHeight: frameRect?.height, - previewFrameWidth: frameRect?.width, + offsetX: (centeredX - freshCoordinates.left) * requestZoom, + offsetY: (centeredY - freshCoordinates.top) * requestZoom, + outputHeight: requestOutputSize.height, + outputWidth: requestOutputSize.width, + previewFrameHeight: baseHeight, + previewFrameWidth: baseWidth, previewUrl: image.previewUrl, sourceHeight: image.height, sourceWidth: image.width, - zoom, + zoom: requestZoom, }); onApply(file); } catch (error: unknown) { @@ -247,33 +170,32 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render return (
-
+

이미지 crop

-

버튼, 범위 입력, 방향키로 위치와 확대를 조정한 뒤 적용합니다.

+

이미지를 이동하거나 확대해 사용할 영역을 선택한 뒤 적용합니다.

-
-
- 선택한 이미지 미리보기 -
+
+

예상 결과 {outputSize.width} × {outputSize.height}px

-
- - - - -
- {applyError === null ? null :

{applyError}

} {isApplying ?

이미지 crop을 적용하는 중

: null} -
- - - +
+ + +