/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * A floating / edge-snapped workspace-panel window (issue #1201). * * Chrome around an arbitrary panel: a drag-by-header title bar, float controls * and a resize affordance. The float controls are two distinct ideas, and the * labels keep them apart (#1264): * - SNAP left / right / bottom / free: positions the *floating overlay*; it * still sits ON TOP of the model (the 3D view stays full size behind it). * - DOCK: re-docks the panel into the sidebar, which RESERVES space so the * panel and the model sit side by side (and can be split, #1266). * * Geometry lives in the dock slice so it persists and survives re-render; this * component only translates pointer gestures into `setFloatingPanelRect` / * `snapFloatingPanel` calls. */ import { useEffect, useRef, type ReactNode } from 'react'; import { PanelLeft, PanelRight, PanelBottom, Square, Pin, X, GripVertical, } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { FloatingPanelState, SnapZone } from '@/store'; import { computeFloatingPanelStyle, type SnapBounds } from './floating-panel-geometry'; export type { SnapBounds }; const MIN_W = 260; const MIN_H = 180; // Keep the opposite edge / header reachable when resizing against the viewport. const RESIZE_EDGE_MARGIN = 40; const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); interface FloatingPanelProps { panel: FloatingPanelState; title: string; zIndex: number; /** The viewport region edge snaps confine to; null until measured. */ bounds: SnapBounds | null; children: ReactNode; onRect: (rect: Partial>) => void; onSnap: (snap: SnapZone) => void; onFocus: () => void; /** Re-dock into the right slot (stop floating, show docked). */ onDock: () => void; onClose: () => void; } export function FloatingPanel({ panel, title, zIndex, bounds, children, onRect, onSnap, onFocus, onDock, onClose, }: FloatingPanelProps) { const ref = useRef(null); // Tear down any in-flight drag / resize listeners if the panel unmounts mid- // gesture (closed / docked while dragging) so stale window listeners don't // keep firing onRect for a panel that no longer exists (#1208). const gestureCleanupRef = useRef<(() => void) | null>(null); useEffect(() => () => gestureCleanupRef.current?.(), []); // Drag by the header: snapped windows convert to free-float at their current // on-screen rect first, so they don't jump. const onDragStart = (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button, input, select, textarea, a, [data-no-drag]')) return; const el = ref.current; if (!el) return; e.preventDefault(); onFocus(); const rect = el.getBoundingClientRect(); const parent = (el.offsetParent as HTMLElement | null)?.getBoundingClientRect(); const startX = rect.left - (parent?.left ?? 0); const startY = rect.top - (parent?.top ?? 0); const w = rect.width; const h = rect.height; const maxX = Math.max(0, (el.offsetParent as HTMLElement | null ? (el.offsetParent as HTMLElement).clientWidth : window.innerWidth) - w); const maxY = Math.max(0, (el.offsetParent as HTMLElement | null ? (el.offsetParent as HTMLElement).clientHeight : window.innerHeight) - h); const px = e.clientX; const py = e.clientY; if (panel.snap !== 'free') onSnap('free'); onRect({ x: startX, y: startY, w, h }); const move = (ev: MouseEvent) => { const x = Math.max(0, Math.min(maxX, startX + ev.clientX - px)); const y = Math.max(0, Math.min(maxY, startY + ev.clientY - py)); onRect({ x, y }); }; const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); gestureCleanupRef.current = null; }; gestureCleanupRef.current = up; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); }; // Resize: free → bottom-right corner; left/right snap → inner edge (width); // bottom snap → top edge (height). const onResizeStart = (e: React.MouseEvent) => { const el = ref.current; if (!el) return; e.preventDefault(); e.stopPropagation(); onFocus(); const rect = el.getBoundingClientRect(); const startW = rect.width; const startH = rect.height; const px = e.clientX; const py = e.clientY; const snap = panel.snap; // Clamp growth so a snapped panel can't be dragged past its dock region // (header / inner edge becoming unreachable). Free panels clamp to the // window; edge-snapped panels clamp to the viewport region (#1208 / #1245). const maxW = snap === 'free' ? Math.max(MIN_W, window.innerWidth - RESIZE_EDGE_MARGIN) : Math.max(MIN_W, bounds?.width ?? window.innerWidth); const maxH = snap === 'free' ? Math.max(MIN_H, window.innerHeight - RESIZE_EDGE_MARGIN) : Math.max(MIN_H, bounds?.height ?? window.innerHeight); const move = (ev: MouseEvent) => { const dx = ev.clientX - px; const dy = ev.clientY - py; if (snap === 'bottom') { onRect({ h: clamp(startH - dy, MIN_H, maxH) }); } else if (snap === 'right') { onRect({ w: clamp(startW - dx, MIN_W, maxW) }); } else if (snap === 'left') { onRect({ w: clamp(startW + dx, MIN_W, maxW) }); } else { onRect({ w: clamp(startW + dx, MIN_W, maxW), h: clamp(startH + dy, MIN_H, maxH) }); } }; const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); gestureCleanupRef.current = null; }; gestureCleanupRef.current = up; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); }; const snapBtn = (zone: SnapZone, Icon: typeof PanelLeft, label: string) => ( ); return (
{/* Title bar — drag handle + dock controls */}
{title}
{/* Float positioning (overlay); the model stays full size behind it. */} {snapBtn('left', PanelLeft, 'Snap left (overlay)')} {snapBtn('bottom', PanelBottom, 'Snap bottom (overlay)')} {snapBtn('right', PanelRight, 'Snap right (overlay)')} {snapBtn('free', Square, 'Free float')} {/* Dock reserves space (panel + model side by side). */}
{/* Panel content */}
{children}
{/* Resize affordance */}
); }