/** * PanelContainer - Orchestrates all floating panels in the canvas view * * This component renders floating panels (Introspection, Validation, Error, Results) * in a portal, positioned absolutely within the canvas area. * * Features: * - Draggable panels * - Z-index management (click to bring to front) * - Keyboard shortcuts (Escape to close all) * - Position persistence via usePanelStore */ import { useState, useCallback, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { usePanelStore, useIntrospectionPanel, useValidationPanel, useErrorPanel, useResultsPanel, PanelPosition, } from '../../../hooks/usePanelStore'; import { FloatingIntrospectionPanel } from './FloatingIntrospectionPanel'; import { FloatingValidationPanel } from './ValidationPanel'; import { ErrorPanel } from './ErrorPanel'; import { ResultsPanel } from './ResultsPanel'; interface PanelContainerProps { /** Container element to render panels into (defaults to document.body) */ container?: HTMLElement; /** Callback when retry is requested from error panel */ onRetry?: (trial?: number) => void; /** Callback when skip trial is requested */ onSkipTrial?: (trial: number) => void; } type PanelName = 'introspection' | 'validation' | 'error' | 'results'; export function PanelContainer({ container, onRetry, onSkipTrial }: PanelContainerProps) { const { closePanel, setPanelPosition, closeAllPanels } = usePanelStore(); const introspectionPanel = useIntrospectionPanel(); const validationPanel = useValidationPanel(); const errorPanel = useErrorPanel(); const resultsPanel = useResultsPanel(); // Track which panel is on top (for z-index) const [topPanel, setTopPanel] = useState(null); // Dragging state const [dragging, setDragging] = useState<{ panel: PanelName; offset: { x: number; y: number } } | null>(null); const dragRef = useRef<{ panel: PanelName; offset: { x: number; y: number } } | null>(null); // Escape key to close all panels useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { closeAllPanels(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [closeAllPanels]); // Mouse move handler for dragging useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!dragRef.current) return; const { panel, offset } = dragRef.current; const newPosition: PanelPosition = { x: e.clientX - offset.x, y: e.clientY - offset.y, }; // Clamp to viewport newPosition.x = Math.max(0, Math.min(window.innerWidth - 100, newPosition.x)); newPosition.y = Math.max(0, Math.min(window.innerHeight - 50, newPosition.y)); setPanelPosition(panel, newPosition); }; const handleMouseUp = () => { dragRef.current = null; setDragging(null); }; if (dragging) { window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); } return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [dragging, setPanelPosition]); // Start dragging a panel const handleDragStart = useCallback((panel: PanelName, e: React.MouseEvent, position: PanelPosition) => { const offset = { x: e.clientX - position.x, y: e.clientY - position.y, }; dragRef.current = { panel, offset }; setDragging({ panel, offset }); setTopPanel(panel); }, []); // Click to bring panel to front const handlePanelClick = useCallback((panel: PanelName) => { setTopPanel(panel); }, []); // Get z-index for a panel const getZIndex = (panel: PanelName) => { const baseZ = 100; if (panel === topPanel) return baseZ + 10; return baseZ; }; // Render a draggable wrapper const renderDraggable = ( panel: PanelName, position: PanelPosition, isOpen: boolean, children: React.ReactNode ) => { if (!isOpen) return null; return (
handlePanelClick(panel)} > {/* Drag handle - the header area */}
handleDragStart(panel, e, position)} style={{ zIndex: 1 }} /> {/* Panel content */}
{children}
); }; // Determine what to render const panels = ( <> {/* Introspection Panel */} {renderDraggable( 'introspection', introspectionPanel.position || { x: 100, y: 100 }, introspectionPanel.open, closePanel('introspection')} /> )} {/* Validation Panel */} {renderDraggable( 'validation', validationPanel.position || { x: 150, y: 150 }, validationPanel.open, closePanel('validation')} /> )} {/* Error Panel */} {renderDraggable( 'error', errorPanel.position || { x: 200, y: 100 }, errorPanel.open, closePanel('error')} onRetry={onRetry} onSkipTrial={onSkipTrial} /> )} {/* Results Panel */} {renderDraggable( 'results', resultsPanel.position || { x: 250, y: 150 }, resultsPanel.open, closePanel('results')} /> )} ); // Use portal if container specified, otherwise render in place if (container) { return createPortal(panels, container); } return panels; } export default PanelContainer;