ERP-node/frontend/components/ui/resizable-dialog.tsx

372 lines
13 KiB
TypeScript
Raw Normal View History

2025-11-05 16:36:32 +09:00
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ResizableDialog = DialogPrimitive.Root;
const ResizableDialogTrigger = DialogPrimitive.Trigger;
const ResizableDialogPortal = DialogPrimitive.Portal;
const ResizableDialogClose = DialogPrimitive.Close;
const ResizableDialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
ResizableDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface ResizableDialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
defaultWidth?: number;
defaultHeight?: number;
modalId?: string; // localStorage 저장용 고유 ID
userId?: string; // 사용자별 저장용
}
const ResizableDialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
ResizableDialogContentProps
>(
(
{
className,
children,
minWidth = 400,
minHeight = 300,
maxWidth = 1400,
maxHeight = 900,
defaultWidth = 600,
defaultHeight = 500,
modalId,
userId = "guest",
style: userStyle,
...props
},
ref
) => {
const contentRef = React.useRef<HTMLDivElement>(null);
// 고정된 ID 생성 (한번 생성되면 컴포넌트 생명주기 동안 유지)
const stableIdRef = React.useRef<string | null>(null);
if (!stableIdRef.current) {
if (modalId) {
stableIdRef.current = modalId;
} else {
// className 기반 ID 생성
if (className) {
const hash = className.split('').reduce((acc, char) => {
return ((acc << 5) - acc) + char.charCodeAt(0);
}, 0);
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
} else if (userStyle) {
// userStyle 기반 ID 생성
const styleStr = JSON.stringify(userStyle);
const hash = styleStr.split('').reduce((acc, char) => {
return ((acc << 5) - acc) + char.charCodeAt(0);
}, 0);
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
} else {
// 기본 ID
stableIdRef.current = 'modal-default';
}
}
}
const effectiveModalId = stableIdRef.current;
// 실제 렌더링된 크기를 감지하여 초기 크기로 사용
const getInitialSize = React.useCallback(() => {
if (typeof window === 'undefined') return { width: defaultWidth, height: defaultHeight };
// 1순위: userStyle에서 크기 추출 (화면관리에서 지정한 크기 - 항상 초기값으로 사용)
if (userStyle) {
const styleWidth = typeof userStyle.width === 'string'
? parseInt(userStyle.width)
: userStyle.width;
const styleHeight = typeof userStyle.height === 'string'
? parseInt(userStyle.height)
: userStyle.height;
if (styleWidth && styleHeight) {
return {
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
};
}
}
// 2순위: 현재 렌더링된 크기 사용
if (contentRef.current) {
const rect = contentRef.current.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
return {
width: Math.max(minWidth, Math.min(maxWidth, rect.width)),
height: Math.max(minHeight, Math.min(maxHeight, rect.height)),
};
}
}
// 3순위: defaultWidth/defaultHeight 사용
return { width: defaultWidth, height: defaultHeight };
}, [defaultWidth, defaultHeight, minWidth, minHeight, maxWidth, maxHeight, userStyle]);
const [size, setSize] = React.useState(getInitialSize);
const [isResizing, setIsResizing] = React.useState(false);
const [resizeDirection, setResizeDirection] = React.useState<string>("");
const [isInitialized, setIsInitialized] = React.useState(false);
// 모달이 열릴 때 초기 크기 설정 (localStorage 우선, 없으면 화면관리 설정)
React.useEffect(() => {
if (!isInitialized) {
const initialSize = getInitialSize();
// localStorage에서 저장된 크기가 있는지 확인
if (effectiveModalId && typeof window !== 'undefined') {
try {
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsed = JSON.parse(saved);
// 저장된 크기가 있으면 그것을 사용 (사용자가 이전에 리사이즈한 크기)
const restoredSize = {
width: Math.max(minWidth, Math.min(maxWidth, parsed.width || initialSize.width)),
height: Math.max(minHeight, Math.min(maxHeight, parsed.height || initialSize.height)),
};
setSize(restoredSize);
setIsInitialized(true);
return;
}
} catch (error) {
console.error("모달 크기 복원 실패:", error);
}
}
// 저장된 크기가 없으면 초기 크기 사용 (화면관리 설정 크기)
setSize(initialSize);
setIsInitialized(true);
}
}, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight]);
const startResize = (direction: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
const startX = e.clientX;
const startY = e.clientY;
const startWidth = size.width;
const startHeight = size.height;
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
let newWidth = startWidth;
let newHeight = startHeight;
if (direction.includes("e")) {
newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + deltaX));
}
if (direction.includes("w")) {
newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth - deltaX));
}
if (direction.includes("s")) {
newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight + deltaY));
}
if (direction.includes("n")) {
newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight - deltaY));
}
setSize({ width: newWidth, height: newHeight });
};
const handleMouseUp = () => {
setIsResizing(false);
setResizeDirection("");
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
// localStorage에 크기 저장 (리사이즈 후 새로고침해도 유지)
if (effectiveModalId && typeof window !== 'undefined') {
try {
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
const currentSize = { width: size.width, height: size.height };
localStorage.setItem(storageKey, JSON.stringify(currentSize));
} catch (error) {
console.error("모달 크기 저장 실패:", error);
}
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
return (
<ResizableDialogPortal>
<ResizableDialogOverlay />
<DialogPrimitive.Content
ref={ref}
{...props}
className={cn(
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
isResizing && "select-none",
className
)}
style={{
...userStyle,
width: `${size.width}px`,
height: `${size.height}px`,
maxWidth: "95vw",
maxHeight: "95vh",
minWidth: `${minWidth}px`,
minHeight: `${minHeight}px`,
}}
>
<div ref={contentRef} className="flex flex-col h-full overflow-hidden">
{children}
</div>
{/* 리사이즈 핸들 */}
{/* 오른쪽 */}
<div
className="absolute right-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
onMouseDown={startResize("e")}
/>
{/* 아래 */}
<div
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
onMouseDown={startResize("s")}
/>
{/* 오른쪽 아래 */}
<div
className="absolute right-0 bottom-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
onMouseDown={startResize("se")}
/>
{/* 왼쪽 */}
<div
className="absolute left-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
onMouseDown={startResize("w")}
/>
{/* 위 */}
<div
className="absolute top-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
onMouseDown={startResize("n")}
/>
{/* 왼쪽 아래 */}
<div
className="absolute left-0 bottom-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
onMouseDown={startResize("sw")}
/>
{/* 오른쪽 위 */}
<div
className="absolute right-0 top-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
onMouseDown={startResize("ne")}
/>
{/* 왼쪽 위 */}
<div
className="absolute left-0 top-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
onMouseDown={startResize("nw")}
/>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</ResizableDialogPortal>
);
}
);
ResizableDialogContent.displayName = DialogPrimitive.Content.displayName;
const ResizableDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left flex-shrink-0",
className
)}
{...props}
/>
);
ResizableDialogHeader.displayName = "ResizableDialogHeader";
const ResizableDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 flex-shrink-0",
className
)}
{...props}
/>
);
ResizableDialogFooter.displayName = "ResizableDialogFooter";
const ResizableDialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
ResizableDialogTitle.displayName = DialogPrimitive.Title.displayName;
const ResizableDialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
ResizableDialogDescription.displayName =
DialogPrimitive.Description.displayName;
export {
ResizableDialog,
ResizableDialogPortal,
ResizableDialogOverlay,
ResizableDialogClose,
ResizableDialogTrigger,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogFooter,
ResizableDialogTitle,
ResizableDialogDescription,
};