204 lines
7.7 KiB
TypeScript
204 lines
7.7 KiB
TypeScript
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<File>;
|
||
};
|
||
|
||
const MOVE_STEP = 10;
|
||
|
||
export function ImageCropDialog({ image, onApply, onCancel, open, policy, renderCrop = createCroppedImageFile }: ImageCropDialogProps) {
|
||
const [applyError, setApplyError] = useState<string | null>(null);
|
||
const [coordinates, setCoordinates] = useState<Coordinates | null>(null);
|
||
const [isApplying, setIsApplying] = useState(false);
|
||
const cropperRef = useRef<CropperRef>(null);
|
||
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(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<HTMLElement>) {
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (isApplying) {
|
||
return;
|
||
}
|
||
onCancel();
|
||
return;
|
||
}
|
||
|
||
trapFocus(event);
|
||
}
|
||
|
||
function handleCropperKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||
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 (
|
||
<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 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>
|
||
</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>
|
||
{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 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>
|
||
);
|
||
}
|