import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode, } from "react"; import { clampPanelGeometry, defaultPanelGeometry, movePanelGeometry, resizePanelGeometry, type PanelGeometry, type PanelMinimums, type PanelPlacement, } from "../panelGeometry"; import { Icon } from "./Icon"; interface FloatingPanelProps { panelId: string; title: string; eyebrow: string; subtitle: string; placement: PanelPlacement; active: boolean; analysisActive?: boolean; open: boolean; embeddedWhenInactive?: boolean; stopDisabled?: boolean; minimumWidth: number; minimumHeight: number; children: ReactNode; onClose: () => void; onStop: () => void; } interface PointerInteraction { kind: "move" | "resize"; pointerId: number; clientX: number; clientY: number; geometry: PanelGeometry; } let highestPanelLayer = 80; function viewportSize() { return { width: typeof window === "undefined" ? 1280 : window.innerWidth, height: typeof window === "undefined" ? 800 : window.innerHeight, }; } function keyboardDelta(event: KeyboardEvent): { x: number; y: number } | null { const amount = event.shiftKey ? 24 : 8; if (event.key === "ArrowLeft") return { x: -amount, y: 0 }; if (event.key === "ArrowRight") return { x: amount, y: 0 }; if (event.key === "ArrowUp") return { x: 0, y: -amount }; if (event.key === "ArrowDown") return { x: 0, y: amount }; return null; } export function FloatingPanel({ panelId, title, eyebrow, subtitle, placement, active, analysisActive = active, open, embeddedWhenInactive = false, stopDisabled = false, minimumWidth, minimumHeight, children, onClose, onStop, }: FloatingPanelProps) { const minimums: PanelMinimums = { width: minimumWidth, height: minimumHeight }; const [geometry, setGeometry] = useState(() => defaultPanelGeometry(placement, viewportSize(), minimums), ); const [layer, setLayer] = useState(() => ++highestPanelLayer); const interactionRef = useRef(null); const wasActiveRef = useRef(false); const bringForward = () => setLayer(++highestPanelLayer); useEffect(() => { if (active && !wasActiveRef.current) { setGeometry(defaultPanelGeometry(placement, viewportSize(), minimums)); bringForward(); } wasActiveRef.current = active; }, [active, minimumHeight, minimumWidth, placement]); useEffect(() => { if (active && open) bringForward(); }, [active, open]); useEffect(() => { if (!active) return undefined; const handleResize = () => { setGeometry((current) => clampPanelGeometry(current, viewportSize(), minimums), ); }; window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, [active, minimumHeight, minimumWidth]); const beginInteraction = ( event: PointerEvent, kind: PointerInteraction["kind"], ) => { if (!active || !open || event.button !== 0) return; if ( kind === "move" && event.target instanceof Element && event.target.closest("button") ) { return; } event.currentTarget.focus({ preventScroll: true }); event.preventDefault(); event.currentTarget.setPointerCapture?.(event.pointerId); interactionRef.current = { kind, pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, geometry, }; bringForward(); }; const continueInteraction = (event: PointerEvent) => { const interaction = interactionRef.current; if (!interaction || interaction.pointerId !== event.pointerId) return; const deltaX = event.clientX - interaction.clientX; const deltaY = event.clientY - interaction.clientY; setGeometry( interaction.kind === "move" ? movePanelGeometry( interaction.geometry, deltaX, deltaY, viewportSize(), minimums, ) : resizePanelGeometry( interaction.geometry, deltaX, deltaY, viewportSize(), minimums, ), ); }; const endInteraction = (event: PointerEvent) => { if (interactionRef.current?.pointerId !== event.pointerId) return; interactionRef.current = null; if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { event.currentTarget.releasePointerCapture?.(event.pointerId); } }; const moveWithKeyboard = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); onClose(); return; } const delta = keyboardDelta(event); if (!delta) return; event.preventDefault(); bringForward(); setGeometry((current) => movePanelGeometry(current, delta.x, delta.y, viewportSize(), minimums), ); }; const resizeWithKeyboard = (event: KeyboardEvent) => { const delta = keyboardDelta(event); if (!delta) return; event.preventDefault(); bringForward(); setGeometry((current) => resizePanelGeometry(current, delta.x, delta.y, viewportSize(), minimums), ); }; const floatingStyle: CSSProperties | undefined = active ? { left: geometry.x, top: geometry.y, width: geometry.width, height: geometry.height, zIndex: layer, } : undefined; const modeClass = active ? open ? "is-floating" : "is-panel-closed" : embeddedWhenInactive ? "is-embedded" : "is-dormant"; return (
{active && (
beginInteraction(event, "move")} onPointerMove={continueInteraction} onPointerUp={endInteraction} onPointerCancel={endInteraction} onKeyDown={moveWithKeyboard} >
{eyebrow} {title} {subtitle}
{analysisActive ? "Analysis active" : "Analysis stopped"}
)}
{children}
{active && ( beginInteraction(event, "resize")} onPointerMove={continueInteraction} onPointerUp={endInteraction} onPointerCancel={endInteraction} onKeyDown={resizeWithKeyboard} > )}
); }