// ============================================================ // FloatingPanel — 非模态可拖拽浮动面板 // // 特性: // - 标题栏拖拽移动,无遮罩,不阻断主页面交互 // - 初始居中定位,支持暗色主题 // - 边界检测(不拖出视口) // - 多个面板可同时打开(点击置顶) // - 关闭按钮 + 最小尺寸约束 // // 对应 QT 原版"非模态窗口"的 Web 映射 // ============================================================ import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react'; import { useTheme } from '@/hooks/use-theme'; // ---------- 类型 ---------- export interface FloatingPanelProps { open: boolean; onClose: () => void; title: string; children: ReactNode; /** 初始宽度(px),默认 480 */ width?: number; /** 初始高度(px),默认 360 */ height?: number; } // ---------- 全局 z-index 管理 ---------- let globalZIndex = 1000; function nextZIndex(): number { globalZIndex += 1; return globalZIndex; } // ---------- 组件 ---------- export function FloatingPanel({ open, onClose, title, children, width = 480, height = 360 }: FloatingPanelProps) { const { isDark } = useTheme(); // 位置与尺寸 const [position, setPosition] = useState<{ x: number; y: number } | null>(null); const [zIndex, setZIndex] = useState(() => nextZIndex()); const panelRef = useRef(null); // 拖拽状态 const draggingRef = useRef(false); const dragStartRef = useRef({ x: 0, y: 0, panelX: 0, panelY: 0 }); // 初始居中(首次 open 时计算) useEffect(() => { if (open && position === null) { const x = Math.max(0, (window.innerWidth - width) / 2); const y = Math.max(0, (window.innerHeight - height) / 2); setPosition({ x, y }); } }, [open, position, width, height]); // 拖拽启动 const handleMouseDown = useCallback( (e: React.MouseEvent) => { // 仅标题栏区域可拖拽 if ((e.target as HTMLElement).closest('.fp-close-btn')) return; e.preventDefault(); draggingRef.current = true; dragStartRef.current = { x: e.clientX, y: e.clientY, panelX: position?.x ?? 0, panelY: position?.y ?? 0, }; }, [position], ); // 全局 mousemove / mouseup useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!draggingRef.current) return; const ds = dragStartRef.current; const newX = ds.panelX + (e.clientX - ds.x); const newY = ds.panelY + (e.clientY - ds.y); // 边界约束:保留标题栏至少 40px 可见,确保用户能拖回 const minVisible = 40; const maxX = window.innerWidth - minVisible; const maxY = window.innerHeight - minVisible; setPosition({ x: Math.max(-width + minVisible, Math.min(maxX, newX)), y: Math.max(0, Math.min(maxY, newY)), }); }; const handleMouseUp = () => { draggingRef.current = false; }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [width]); // 点击面板 → 置顶 const bringToFront = useCallback(() => { setZIndex(nextZIndex()); }, []); if (!open || !position) return null; return (
{/* 标题栏 — 拖拽手柄 */}
{title}
{/* 内容区 */}
{children}
); }