feat(ai-character): 리소스 관리 기반 정비

This commit is contained in:
Yu Sung
2026-08-01 01:30:24 +09:00
parent 55ba0df77a
commit a1cae336d9
73 changed files with 5162 additions and 816 deletions

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { calculateCropOutputSize, createCroppedImageFile } from "@/shared/lib/crop-image";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
@@ -8,6 +8,7 @@ export type CropSourceImage = {
readonly file: File;
readonly height: number;
readonly previewUrl: string;
readonly release?: () => void;
readonly width: number;
};
@@ -29,13 +30,54 @@ 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 [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 { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width });
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 });
}, []);
if (!open) {
return null;
@@ -56,7 +98,45 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render
setZoom(Math.min(3, Math.max(1, Number(nextZoom.toFixed(1)))));
}
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
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 handleDialogKeyDown(event: React.KeyboardEvent<HTMLElement>) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onCancel();
return;
}
trapFocus(event);
}
function handlePreviewKeyDown(event: React.KeyboardEvent<HTMLElement>) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
@@ -87,11 +167,32 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render
}
function startDrag(event: React.PointerEvent<HTMLElement>) {
dragPointRef.current = { x: event.clientX, y: event.clientY };
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;
@@ -101,51 +202,78 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render
dragPointRef.current = { x: event.clientX, y: event.clientY };
}
function stopDrag() {
function stopDrag(event: React.PointerEvent<HTMLElement>) {
pointersRef.current.delete(event.pointerId);
event.currentTarget.releasePointerCapture?.(event.pointerId);
pinchRef.current = null;
dragPointRef.current = null;
}
async function applyCrop() {
const file = await renderCrop({
aspect: policy.aspect,
file: image.file,
offsetX,
offsetY,
outputHeight: outputSize.height,
outputWidth: outputSize.width,
previewUrl: image.previewUrl,
sourceHeight: image.height,
sourceWidth: image.width,
zoom,
});
onApply(file);
if (isApplying) {
return;
}
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,
previewUrl: image.previewUrl,
sourceHeight: image.height,
sourceWidth: image.width,
zoom,
});
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 w-full max-w-lg flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="dialog">
<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">
<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 미리보기" className="overflow-hidden rounded-lg border border-border bg-muted p-4" onKeyDown={handleKeyDown} onPointerDown={startDrag} onPointerLeave={stopDrag} onPointerMove={drag} onPointerUp={stopDrag} role="application" tabIndex={0}>
<img alt="선택한 이미지 미리보기" className="mx-auto max-h-64 max-w-full" src={image.previewUrl} style={{ transform: `translate(${offsetX}px, ${offsetY}px) scale(${zoom})` }} />
<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>
<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" 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" 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" 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" 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(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="확대 비율" max="3" min="1" onChange={(event) => changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} />
<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" onClick={resetCrop} type="button"></button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" 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)]" onClick={() => void applyCrop()} 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={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>
</section>
</div>