Files
voiceon-character-admin/src/shared/ui/image-crop-dialog.tsx

204 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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