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

602 lines
25 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; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
disableFlexLayout?: boolean; // 🆕 flex 레이아웃 비활성화 (absolute 레이아웃용)
}
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 상태
disableFlexLayout = false, // 🆕 flex 레이아웃 비활성화
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) {
const finalSize = {
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
};
return finalSize;
}
}
// 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);
// userStyle이 변경되면 크기 업데이트 (화면 데이터 로딩 완료 시)
React.useEffect(() => {
// 1. localStorage에서 사용자가 리사이징한 크기 확인
let savedSize: { width: number; height: number; userResized: boolean } | null = null;
if (effectiveModalId && typeof window !== 'undefined') {
try {
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.userResized) {
savedSize = {
width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
userResized: true,
};
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
}
}
} catch (error) {
console.error("❌ 모달 크기 복원 실패:", error);
}
}
// 2. 우선순위: 사용자 리사이징 > userStyle > 기본값
if (savedSize && savedSize.userResized) {
// 사용자가 리사이징한 크기 우선
setSize({ width: savedSize.width, height: savedSize.height });
setUserResized(true);
} else if (userStyle && userStyle.width && userStyle.height) {
// 화면관리에서 설정한 크기
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) {
const newSize = {
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
};
setSize(newSize);
}
}
}, [userStyle, minWidth, maxWidth, minHeight, maxHeight, effectiveModalId, userId]);
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와 내용 크기 중 큰 값 사용)
// 주석처리 - 사용자가 설정한 크기(userStyle)만 사용하도록 변경
// 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="h-full w-full relative"
style={{ display: 'block', overflow: 'auto', pointerEvents: 'auto', zIndex: 1 }}
>
{children}
</div>
{/* 리사이즈 핸들 */}
{/* 오른쪽 */}
<div
className="absolute right-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("e")}
/>
{/* 아래 */}
<div
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("s")}
/>
{/* 오른쪽 아래 */}
<div
className="absolute right-0 bottom-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("se")}
/>
{/* 왼쪽 */}
<div
className="absolute left-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("w")}
/>
{/* 위 */}
<div
className="absolute top-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("n")}
/>
{/* 왼쪽 아래 */}
<div
className="absolute left-0 bottom-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("sw")}
/>
{/* 오른쪽 위 */}
<div
className="absolute right-0 top-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("ne")}
/>
{/* 왼쪽 위 */}
<div
className="absolute left-0 top-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("nw")}
/>
{/* 리셋 버튼 (사용자가 리사이징한 경우만 표시) */}
{userResized && (
<button
onClick={() => {
// localStorage에서 저장된 크기 삭제
if (effectiveModalId && typeof window !== 'undefined') {
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
localStorage.removeItem(storageKey);
console.log("🗑️ 저장된 모달 크기 삭제:", storageKey);
}
// 화면관리 설정 크기로 복원
const initialSize = getInitialSize();
setSize(initialSize);
setUserResized(false);
console.log("🔄 기본 크기로 리셋:", initialSize);
}}
className="absolute right-12 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"
style={{ zIndex: 20 }}
title="기본 크기로 리셋"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M3 21v-5h5"/>
</svg>
<span className="sr-only"> </span>
</button>
)}
<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"
style={{ zIndex: 20 }}
>
<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,
};