메뉴들 플로팅 패널로 구현
This commit is contained in:
236
frontend/components/screen/FloatingPanel.tsx
Normal file
236
frontend/components/screen/FloatingPanel.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"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 = 800,
|
||||
resizable = true,
|
||||
draggable = true,
|
||||
autoHeight = false, // 자동 높이 조정 비활성화 (수동 크기 조절만 지원)
|
||||
className,
|
||||
}) => {
|
||||
const [panelSize, setPanelSize] = useState({ 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]);
|
||||
|
||||
// 자동 높이 조정 기능 제거됨 - 수동 크기 조절만 지원
|
||||
|
||||
// 드래그 시작 - 성능 최적화
|
||||
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-50 rounded-lg border border-gray-200 bg-white shadow-lg",
|
||||
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 : 50, // 드래그 중 최상위 표시
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
ref={dragHandleRef}
|
||||
data-header="true"
|
||||
className="flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 p-3"
|
||||
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 p-1 transition-colors hover:bg-gray-200">
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex-1 overflow-auto"
|
||||
style={{
|
||||
maxHeight: `${panelSize.height - 60}px`, // 헤더 높이 제외
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 리사이즈 핸들 */}
|
||||
{resizable && (
|
||||
<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-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingPanel;
|
||||
Reference in New Issue
Block a user