293 lines
8.9 KiB
TypeScript
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-[100] 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 ? 101 : 100, // 항상 컴포넌트보다 위에 표시
|
|
}}
|
|
>
|
|
{/* 헤더 */}
|
|
<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;
|