import { useCallback, useEffect, useRef, type ReactNode } from "react"; import { createPortal } from "react-dom"; export const DITHERED_SHADOW = ` 3px 3px 0 0 var(--border), 5px 3px 0 0 transparent, 7px 3px 0 0 var(--border), 4px 4px 0 0 transparent, 6px 4px 0 0 var(--border), 3px 5px 0 0 var(--border), 5px 5px 0 0 transparent, 7px 5px 0 0 var(--border), 4px 6px 0 0 var(--border), 6px 6px 0 0 transparent `; export interface FloatingWindowProps { id: string; title: string; x: number; y: number; w: number; h: number; zIndex: number; focused: boolean; onUpdate: (id: string, patch: { x?: number; y?: number; w?: number; h?: number }) => void; onClose: (id: string) => void; onFocus: (id: string) => void; minW?: number; minH?: number; addressBar?: ReactNode; footer?: ReactNode; containerRef?: React.RefObject; children: ReactNode; } export default function FloatingWindow({ id, title, x, y, w, h, zIndex, focused, onUpdate, onClose, onFocus, minW = 320, minH = 200, addressBar, footer, containerRef, children, }: FloatingWindowProps) { const internalRef = useRef(null); const ref = containerRef ?? internalRef; const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null); const velocityRef = useRef({ vx: 0, vy: 0, lastX: 0, lastY: 0, lastT: 0 }); const inertiaRef = useRef(0); const posRef = useRef({ x, y }); posRef.current = { x, y }; useEffect(() => { if (focused) ref.current?.focus(); }, [focused]); // Cancel any running inertia animation const stopInertia = useCallback(() => { if (inertiaRef.current) { cancelAnimationFrame(inertiaRef.current); inertiaRef.current = 0; } }, []); const onDragStart = useCallback((e: React.MouseEvent) => { if ((e.target as HTMLElement).closest("button")) return; e.preventDefault(); onFocus(id); ref.current?.focus(); stopInertia(); dragRef.current = { startX: e.clientX, startY: e.clientY, origX: x, origY: y }; velocityRef.current = { vx: 0, vy: 0, lastX: e.clientX, lastY: e.clientY, lastT: performance.now() }; document.documentElement.classList.add("cursor-grabbing"); const onMove = (ev: MouseEvent) => { if (!dragRef.current) return; const now = performance.now(); const dt = now - velocityRef.current.lastT; if (dt > 0) { const smooth = 0.3; const rawVx = (ev.clientX - velocityRef.current.lastX) / dt * 16; const rawVy = (ev.clientY - velocityRef.current.lastY) / dt * 16; velocityRef.current.vx = velocityRef.current.vx * (1 - smooth) + rawVx * smooth; velocityRef.current.vy = velocityRef.current.vy * (1 - smooth) + rawVy * smooth; velocityRef.current.lastX = ev.clientX; velocityRef.current.lastY = ev.clientY; velocityRef.current.lastT = now; } onUpdate(id, { x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + (ev.clientY - dragRef.current.startY)), }); }; const onUp = () => { const { vx, vy } = velocityRef.current; dragRef.current = null; document.documentElement.classList.remove("cursor-grabbing"); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); // Kick off inertia if there's meaningful velocity if (Math.abs(vx) > 0.5 || Math.abs(vy) > 0.5) { let curVx = vx; let curVy = vy; const friction = 0.92; const el = ref.current; const tick = () => { curVx *= friction; curVy *= friction; if (Math.abs(curVx) < 0.3 && Math.abs(curVy) < 0.3) { inertiaRef.current = 0; // Sync final position to React state once onUpdate(id, posRef.current); return; } posRef.current = { x: posRef.current.x + curVx, y: Math.max(0, posRef.current.y + curVy) }; // Direct DOM update during animation — skip React reconciliation if (el) { el.style.left = `${posRef.current.x}px`; el.style.top = `${posRef.current.y}px`; } inertiaRef.current = requestAnimationFrame(tick); }; inertiaRef.current = requestAnimationFrame(tick); } }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [id, x, y, onUpdate, onFocus, stopInertia]); const onResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onFocus(id); resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: w, origH: h }; document.documentElement.classList.add("cursor-nwse-resize"); const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return; onUpdate(id, { w: Math.max(minW, resizeRef.current.origW + (ev.clientX - resizeRef.current.startX)), h: Math.max(minH, resizeRef.current.origH + (ev.clientY - resizeRef.current.startY)) }); }; const onUp = () => { resizeRef.current = null; document.documentElement.classList.remove("cursor-nwse-resize"); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [id, w, h, minW, minH, onUpdate, onFocus]); return createPortal(
{ if (e.key === "Escape") onClose(id); }} onMouseDown={() => onFocus(id)} className="fixed z-999 flex flex-col bg-popover text-popover-foreground border-2 rounded-lg outline-none transition-[border-color,opacity] duration-150" style={{ left: x, top: y, width: w, height: h, zIndex: 999 + zIndex, borderColor: focused ? "var(--primary)" : "var(--border)", opacity: focused ? 1 : 0.85, boxShadow: DITHERED_SHADOW }}> {/* Title bar */}
{title}
{/* Optional address bar */} {addressBar} {/* Content */}
{children}
{/* Optional footer */} {footer} {/* Resize handle */}
, document.body); }