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

502 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"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";
// 🆕 Context를 사용하여 open 상태 공유
const ResizableDialogContext = React.createContext<{ open: boolean }>({ open: false });
// 🆕 ResizableDialog를 래핑하여 Context 제공
const ResizableDialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
children,
open = false,
...props
}) => {
return (
<ResizableDialogContext.Provider value={{ open }}>
<DialogPrimitive.Root open={open} {...props}>
{children}
</DialogPrimitive.Root>
</ResizableDialogContext.Provider>
);
};
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; // 사용자별 저장용
open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
}
const ResizableDialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
ResizableDialogContentProps
>(
(
{
className,
children,
minWidth = 400,
minHeight = 300,
maxWidth = 1600,
maxHeight = 1200,
defaultWidth = 600,
defaultHeight = 500,
modalId,
userId = "guest",
open: externalOpen, // 🆕 외부에서 전달받은 open 상태
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;
// // console.log("✅ ResizableDialog - 명시적 modalId 사용:", 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)}`;
// console.log("🔄 ResizableDialog - className 기반 ID 생성:", { className, generatedId: stableIdRef.current });
} 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)}`;
// console.log("🔄 ResizableDialog - userStyle 기반 ID 생성:", { userStyle, generatedId: stableIdRef.current });
} else {
// 기본 ID
stableIdRef.current = 'modal-default';
// console.log("⚠️ ResizableDialog - 기본 ID 사용 (모든 모달이 같은 크기 공유)");
}
}
}
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);
const [lastModalId, setLastModalId] = React.useState<string | null>(null);
const [userResized, setUserResized] = React.useState(false); // 사용자가 실제로 리사이징했는지 추적
// 🆕 Context에서 open 상태 가져오기 (우선순위: externalOpen > context.open)
const context = React.useContext(ResizableDialogContext);
const actualOpen = externalOpen !== undefined ? externalOpen : context.open;
// 🆕 모달이 닫혔다가 다시 열릴 때 초기화 리셋
const [wasOpen, setWasOpen] = React.useState(false);
React.useEffect(() => {
// console.log("🔍 모달 상태 변화 감지:", { actualOpen, wasOpen, externalOpen, contextOpen: context.open, effectiveModalId });
if (actualOpen && !wasOpen) {
// 모달이 방금 열림
// console.log("🔓 모달 열림 감지, 초기화 리셋:", { effectiveModalId });
setIsInitialized(false);
setWasOpen(true);
} else if (!actualOpen && wasOpen) {
// 모달이 방금 닫힘
// console.log("🔒 모달 닫힘 감지:", { effectiveModalId });
setWasOpen(false);
}
}, [actualOpen, wasOpen, effectiveModalId, externalOpen, context.open]);
// modalId가 변경되면 초기화 리셋 (다른 모달이 열린 경우)
React.useEffect(() => {
if (effectiveModalId !== lastModalId) {
// console.log("🔄 모달 ID 변경 감지, 초기화 리셋:", { 이전: lastModalId, 현재: effectiveModalId, isInitialized });
setIsInitialized(false);
setUserResized(false); // 사용자 리사이징 플래그도 리셋
setLastModalId(effectiveModalId);
}
}, [effectiveModalId, lastModalId, isInitialized]);
// 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용)
React.useEffect(() => {
// console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId });
if (!isInitialized) {
// 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
// 여러 번 시도하여 contentRef가 준비될 때까지 대기
let attempts = 0;
const maxAttempts = 10;
const measureContent = () => {
attempts++;
// scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함)
let contentWidth = defaultWidth;
let contentHeight = defaultHeight;
if (contentRef.current) {
// scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거)
contentWidth = contentRef.current.scrollWidth || defaultWidth;
contentHeight = contentRef.current.scrollHeight || defaultHeight;
// console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight });
} else {
// console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight });
// contentRef가 아직 없으면 재시도
if (attempts < maxAttempts) {
setTimeout(measureContent, 100);
return;
}
}
// 패딩 추가 (p-6 * 2 = 48px)
const paddingAndMargin = 48;
const initialSize = getInitialSize();
// 내용 크기 기반 최소 크기 계산
const contentBasedSize = {
width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))),
height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))),
};
// console.log("📐 내용 기반 크기:", contentBasedSize);
// localStorage에서 저장된 크기 확인
let finalSize = contentBasedSize;
if (effectiveModalId && typeof window !== 'undefined') {
try {
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
const saved = localStorage.getItem(storageKey);
// console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" });
if (saved) {
const parsed = JSON.parse(saved);
// userResized 플래그 확인
if (parsed.userResized) {
const savedSize = {
width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
};
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
// ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용
// (사용자가 의도적으로 작게 만든 것을 존중)
finalSize = savedSize;
setUserResized(true);
// console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" });
} else {
// console.log(" 자동 계산된 크기는 무시, 내용 크기 사용");
}
} else {
// console.log(" localStorage에 저장된 크기 없음, 내용 크기 사용");
}
} catch (error) {
// console.error("❌ 모달 크기 복원 실패:", error);
}
}
setSize(finalSize);
setIsInitialized(true);
};
// 첫 시도는 300ms 후에 시작
setTimeout(measureContent, 300);
}
}, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]);
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);
// 사용자가 리사이징했음을 표시
setUserResized(true);
// ✅ 중요: 현재 실제 DOM 크기를 저장 (state가 아닌 실제 크기)
if (effectiveModalId && typeof window !== 'undefined' && contentRef.current) {
try {
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
// contentRef의 부모 요소(모달 컨테이너)의 실제 크기 사용
const modalElement = contentRef.current.parentElement;
const actualWidth = modalElement?.offsetWidth || size.width;
const actualHeight = modalElement?.offsetHeight || size.height;
const currentSize = {
width: actualWidth,
height: actualHeight,
userResized: true, // 사용자가 직접 리사이징했음을 표시
};
localStorage.setItem(storageKey, JSON.stringify(currentSize));
// console.log("💾 localStorage에 크기 저장 (사용자 리사이징):", { effectiveModalId, userId, storageKey, size: currentSize, stateSize: { width: size.width, height: size.height } });
} 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-auto">
{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,
};