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"; import { useModalFocus } from "@/shared/ui/use-modal-focus"; export type CropSourceImage = { readonly file: File; readonly height: number; readonly previewUrl: string; readonly release?: () => void; readonly width: number; }; export type ImageCropPolicy = { readonly aspect: number | "free"; readonly maxWidth: number; readonly noUpscale: boolean; }; export type ImageCropDialogProps = { readonly image: CropSourceImage; readonly onApply: (file: File) => void; readonly onCancel: () => void; readonly open: boolean; readonly policy: ImageCropPolicy; readonly renderCrop?: (request: CropRenderRequest) => Promise; }; const MOVE_STEP = 10; export function ImageCropDialog({ image, onApply, onCancel, open, policy, renderCrop = createCroppedImageFile }: ImageCropDialogProps) { const [applyError, setApplyError] = useState(null); const [coordinates, setCoordinates] = useState(null); const [isApplying, setIsApplying] = useState(false); const cropperRef = useRef(null); const { dialogRef, trapFocus } = useModalFocus(open); const sourceAspect = image.width / image.height; 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 updateCoordinates(cropper: CropperRef) { setCoordinates(cropper.getCoordinates()); } function handleDialogKeyDown(event: React.KeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); if (isApplying) { return; } onCancel(); return; } trapFocus(event); } function handleCropperKeyDown(event: React.KeyboardEvent) { const cropper = cropperRef.current; if (cropper === null) { return; } switch (event.key) { case "ArrowDown": event.preventDefault(); cropper.moveImage(0, MOVE_STEP); return; case "ArrowLeft": event.preventDefault(); cropper.moveImage(-MOVE_STEP, 0); return; case "ArrowRight": event.preventDefault(); cropper.moveImage(MOVE_STEP, 0); return; case "ArrowUp": event.preventDefault(); cropper.moveImage(0, -MOVE_STEP); return; case "+": case "=": event.preventDefault(); cropper.zoomImage(1.1); return; case "-": event.preventDefault(); cropper.zoomImage(0.9); return; default: } } 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); try { const file = await renderCrop({ aspect: policy.aspect, file: image.file, 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: requestZoom, }); onApply(file); } catch (error: unknown) { if (!(error instanceof Error)) { throw error; } setApplyError("이미지 crop을 적용하지 못했습니다."); } finally { setIsApplying(false); } } return (

이미지 crop

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

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

{applyError === null ? null :

{applyError}

} {isApplying ?

이미지 crop을 적용하는 중

: null}
); }