feat(ai-character): 이미지 crop 조작 개선

This commit is contained in:
Yu Sung
2026-08-04 18:17:23 +09:00
parent bd37e05d82
commit 766a06ad0a
7 changed files with 339 additions and 332 deletions

View File

@@ -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<typeof import("react")>("react");
return {
Cropper: forwardRef<FakeCropperHandle, FakeCropperProps>(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 <div data-testid="advanced-cropper" />;
}),
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(<ImageCropDialog {...props} />);
}
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<void>): Promise<void> {
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(<ImageCropDialog image={image} onApply={onApply} onCancel={onCancel} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
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<File>>(() => 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(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 2, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
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(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
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(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 210 / 297, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
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(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 210 / 297, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
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<File>((_resolve, reject) => {
rejectCrop = reject;
}));
render(<ImageCropDialog image={image} onApply={onApply} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
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(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
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(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
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(
<div onKeyDown={(event) => {
if (event.key === "Escape") {
onParentEscape();
}
}}>
<ImageCropDialog image={image} onApply={vi.fn()} onCancel={onCancel} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />
<ImageCropDialog image={image} onApply={vi.fn()} onCancel={onCancel} open policy={squarePolicy} />
</div>,
);
@@ -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<File>((resolve) => {
resolveCrop = resolve;
}));
renderDialog({ image, onApply, onCancel, open: true, policy: squarePolicy, renderCrop });
render(<ImageCropDialog image={{ ...image, height: 600, width: 1200 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: "free", maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
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(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open={false} policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
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();
});

View File

@@ -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<string | null>(null);
const [coordinates, setCoordinates] = useState<Coordinates | null>(null);
const [isApplying, setIsApplying] = useState(false);
const [viewportSize, setViewportSize] = useState<CropFrameSize | null>(null);
const [zoom, setZoom] = useState(1);
const dragPointRef = useRef<{ readonly x: number; readonly y: number } | null>(null);
const pinchRef = useRef<PinchState | null>(null);
const cropViewportRef = useRef<HTMLDivElement>(null);
const previewImageRef = useRef<HTMLImageElement>(null);
const pointersRef = useRef(new Map<number, PointerPoint>());
const cropperRef = useRef<CropperRef>(null);
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(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<HTMLElement>) {
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<HTMLElement>) {
function handleCropperKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
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<HTMLElement>) {
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<HTMLElement>) {
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<HTMLElement>) {
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 (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-label="이미지 crop" aria-modal="true" className="flex w-full max-w-lg flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={handleDialogKeyDown} ref={dialogRef} role="dialog">
<section aria-label="이미지 crop" aria-modal="true" className="flex max-h-[calc(100dvh-2rem)] w-full max-w-lg flex-col gap-4 overflow-y-auto rounded-lg border border-border bg-card p-4 sm:p-6" onKeyDown={handleDialogKeyDown} ref={dialogRef} role="dialog">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold"> crop</h2>
<p className="text-sm text-muted-foreground">, , .</p>
<p className="text-sm text-muted-foreground"> .</p>
</div>
<div aria-label="이미지 crop 미리보기" className="overflow-hidden rounded-lg border border-border bg-muted p-4" onKeyDown={handlePreviewKeyDown} onPointerCancel={stopDrag} onPointerDown={startDrag} onPointerLeave={stopDrag} onPointerMove={drag} onPointerUp={stopDrag} role="application" style={{ touchAction: "none" }} tabIndex={0}>
<div aria-label="이미지 crop viewport" className="relative mx-auto overflow-hidden rounded-md border border-info/70 bg-background" ref={setCropViewportNode} style={{ aspectRatio: String(cropFrameAspect), width: `${Math.min(16 * cropFrameAspect, 32)}rem`, maxWidth: "100%" }}>
<img alt="선택한 이미지 미리보기" className={`absolute left-1/2 top-1/2 ${coverImageClass}`} ref={previewImageRef} src={image.previewUrl} style={{ transform: `translate(-50%, -50%) translate(${clampedOffset.x}px, ${clampedOffset.y}px) scale(${zoom})` }} />
</div>
<div aria-label="이미지 crop viewport" className="overflow-hidden rounded-lg border border-border bg-muted p-2 sm:p-4" onKeyDown={handleCropperKeyDown} role="application" tabIndex={0}>
<Cropper
canvas={false}
checkOrientation={false}
className="h-[min(56vh,28rem)] min-h-64 w-full rounded-md bg-muted"
disabled={isApplying}
imageRestriction={ImageRestriction.stencil}
onChange={updateCoordinates}
onReady={updateCoordinates}
ref={cropperRef}
src={image.previewUrl}
stencilProps={{ aspectRatio: resolvedAspect, grid: true }}
/>
</div>
<p className="text-sm font-semibold text-info"> {outputSize.width} × {outputSize.height}px</p>
<div className="grid gap-2 sm:grid-cols-2">
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(0, -MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(0, MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(-MOVE_STEP, 0)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(MOVE_STEP, 0)} type="button"> </button>
</div>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="확대 비율" disabled={isApplying} max="3" min="1" onChange={(event) => changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} />
</label>
{applyError === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{applyError}</p>}
{isApplying ? <p className="rounded-md border border-border bg-card p-3 text-sm font-semibold" role="status"> crop을 </p> : null}
<div className="flex justify-end gap-2">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={resetCrop} type="button"></button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={onCancel} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isApplying} onClick={() => void applyCrop()} type="button"></button>
<div className="flex flex-wrap justify-end gap-2">
<button className="min-h-11 rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={resetCrop} type="button"></button>
<button className="min-h-11 rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={onCancel} type="button"></button>
<button className="min-h-11 rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isApplying} onClick={() => void applyCrop()} type="button"></button>
</div>
</section>
</div>