feat(ai-character): 이미지 crop 조작 개선
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user