ERP-node/frontend/components/screen/FloatingPanel.tsx

293 lines
8.9 KiB
TypeScript

"use client";
import React, { useState, useRef, useEffect } from "react";
import { X, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
interface FloatingPanelProps {
id: string;
title: string;
children: React.ReactNode;
isOpen: boolean;
onClose: () => void;
position?: "left" | "right" | "top" | "bottom";
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
resizable?: boolean;
draggable?: boolean;
autoHeight?: boolean; // 자동 높이 조정 옵션
className?: string;
}
export const FloatingPanel: React.FC<FloatingPanelProps> = ({
id,
title,
children,
isOpen,
onClose,
position = "right",
width = 320,
height = 400,
minWidth = 280,
minHeight = 300,
maxWidth = 600,
maxHeight = 1200, // 800 → 1200 (더 큰 패널 지원)
resizable = true,
draggable = true,
autoHeight = true, // 자동 높이 조정 활성화 (컨텐츠 크기에 맞게)
className,
}) => {
const [panelSize, setPanelSize] = useState({ width, height });
// props 변경 시 패널 크기 업데이트
useEffect(() => {
setPanelSize({ width, height });
}, [width, height]);
const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const panelRef = useRef<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 초기 위치 설정 (패널이 처음 열릴 때만)
const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => {
if (isOpen && !hasInitialized && panelRef.current) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let initialX = 0;
let initialY = 0;
switch (position) {
case "left":
initialX = 20;
initialY = 80;
break;
case "right":
initialX = viewportWidth - panelSize.width - 20;
initialY = 80;
break;
case "top":
initialX = (viewportWidth - panelSize.width) / 2;
initialY = 20;
break;
case "bottom":
initialX = (viewportWidth - panelSize.width) / 2;
initialY = viewportHeight - panelSize.height - 20;
break;
}
setPanelPosition({ x: initialX, y: initialY });
setHasInitialized(true);
}
// 패널이 닫힐 때 초기화 상태 리셋
if (!isOpen) {
setHasInitialized(false);
}
}, [isOpen, position, hasInitialized]);
// 자동 높이 조정 기능
useEffect(() => {
if (!autoHeight || !contentRef.current || isResizing) return;
const updateHeight = () => {
if (!contentRef.current) return;
// 일시적으로 높이 제한을 해제하여 실제 컨텐츠 높이 측정
contentRef.current.style.maxHeight = "none";
// 컨텐츠의 실제 높이 측정
const contentHeight = contentRef.current.scrollHeight;
const headerHeight = 60; // 헤더 높이
const padding = 30; // 여유 공간 (좀 더 넉넉하게)
const newHeight = Math.min(Math.max(minHeight, contentHeight + headerHeight + padding), maxHeight);
console.log(`🔧 패널 높이 자동 조정:`, {
panelId: id,
contentHeight,
calculatedHeight: newHeight,
currentHeight: panelSize.height,
willUpdate: Math.abs(panelSize.height - newHeight) > 10,
});
// 현재 높이와 다르면 업데이트
if (Math.abs(panelSize.height - newHeight) > 10) {
setPanelSize((prev) => ({ ...prev, height: newHeight }));
}
};
// 초기 높이 설정
updateHeight();
// ResizeObserver로 컨텐츠 크기 변화 감지
const resizeObserver = new ResizeObserver((entries) => {
// DOM 업데이트가 완료된 후에 높이 측정
requestAnimationFrame(() => {
setTimeout(updateHeight, 50); // 약간의 지연으로 렌더링 완료 후 측정
});
});
resizeObserver.observe(contentRef.current);
return () => {
resizeObserver.disconnect();
};
}, [autoHeight, minHeight, maxHeight, isResizing, panelSize.height, children]);
// 드래그 시작 - 성능 최적화
const handleDragStart = (e: React.MouseEvent) => {
if (!draggable) return;
e.preventDefault(); // 기본 동작 방지로 딜레이 제거
e.stopPropagation(); // 이벤트 버블링 방지
setIsDragging(true);
const rect = panelRef.current?.getBoundingClientRect();
if (rect) {
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
// 리사이즈 시작
const handleResizeStart = (e: React.MouseEvent) => {
if (!resizable) return;
e.preventDefault();
setIsResizing(true);
};
// 마우스 이동 처리 - 초고속 최적화
useEffect(() => {
if (!isDragging && !isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
// 직접 DOM 조작으로 최고 성능
if (panelRef.current) {
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
panelRef.current.style.left = `${newX}px`;
panelRef.current.style.top = `${newY}px`;
// 상태는 throttle로 업데이트
setPanelPosition({ x: newX, y: newY });
}
} else if (isResizing) {
const newWidth = Math.max(minWidth, Math.min(maxWidth, e.clientX - panelPosition.x));
const newHeight = Math.max(minHeight, Math.min(maxHeight, e.clientY - panelPosition.y));
if (panelRef.current) {
panelRef.current.style.width = `${newWidth}px`;
panelRef.current.style.height = `${newHeight}px`;
}
setPanelSize({ width: newWidth, height: newHeight });
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
};
// 고성능 이벤트 리스너
document.addEventListener("mousemove", handleMouseMove, {
passive: true,
capture: false,
});
document.addEventListener("mouseup", handleMouseUp, {
passive: true,
capture: false,
});
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging, isResizing, dragOffset.x, dragOffset.y, panelPosition.x, minWidth, maxWidth, minHeight, maxHeight]);
if (!isOpen) return null;
return (
<div
ref={panelRef}
className={cn(
"fixed z-[9998] rounded-xl border border-gray-200/60 bg-white/95 backdrop-blur-sm shadow-xl shadow-gray-900/10",
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
isResizing && "cursor-se-resize",
className,
)}
style={{
left: `${panelPosition.x}px`,
top: `${panelPosition.y}px`,
width: `${panelSize.width}px`,
height: `${panelSize.height}px`,
transform: isDragging ? "scale(1.01)" : "scale(1)",
transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out",
zIndex: isDragging ? 9999 : 9998, // 항상 컴포넌트보다 위에 표시
}}
>
{/* 헤더 */}
<div
ref={dragHandleRef}
data-header="true"
className="flex cursor-move items-center justify-between rounded-t-xl border-b border-gray-200/60 bg-gradient-to-r from-gray-50 to-slate-50 p-4"
onMouseDown={handleDragStart}
style={{
userSelect: "none", // 텍스트 선택 방지
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}
>
<div className="flex items-center space-x-2">
<GripVertical className="h-4 w-4 text-gray-400" />
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
</div>
<button onClick={onClose} className="rounded-lg p-2 transition-all duration-200 hover:bg-white/80 hover:shadow-sm">
<X className="h-4 w-4 text-gray-500 hover:text-gray-700" />
</button>
</div>
{/* 컨텐츠 */}
<div
ref={contentRef}
className={autoHeight ? "flex-1" : "flex-1 overflow-auto"}
style={
autoHeight
? {}
: {
maxHeight: `${panelSize.height - 60}px`, // 헤더 높이 제외
}
}
>
{children}
</div>
{/* 리사이즈 핸들 */}
{resizable && !autoHeight && (
<div className="absolute right-0 bottom-0 h-4 w-4 cursor-se-resize" onMouseDown={handleResizeStart}>
<div className="absolute right-1 bottom-1 h-2 w-2 rounded-sm bg-gradient-to-br from-gray-400 to-gray-500 shadow-sm" />
</div>
)}
</div>
);
};
export default FloatingPanel;