import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { Rnd } from "react-rnd"; import { Maximize2, Minimize2, X } from "lucide-react"; import { Button } from "./button"; import { floatingWindowHost, floatingWindowOrder, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer"; type Bounds = { x: number; y: number; width: number; height: number }; function fitWindow(bounds: Bounds): Bounds { const width = Math.min(bounds.width, window.innerWidth - 16); const height = Math.min(bounds.height, window.innerHeight - 16); return { width, height, x: Math.max(8, Math.min(bounds.x, window.innerWidth - width - 8)), y: Math.max(8, Math.min(bounds.y, window.innerHeight - height - 8)) }; } export function FloatingWindow({ title, triggerLabel, fillContent = false, openRequestKey, children }: { title: string; triggerLabel?: string; fillContent?: boolean; /** A new display request opens the window without taking keyboard focus. */ openRequestKey?: string; children: (size: { width: number; height: number }) => ReactNode; }) { const id = useId(); const titleId = useId(); const [manuallyOpen, setManuallyOpen] = useState(false); const [request, setRequest] = useState({ key: openRequestKey, dismissed: false }); // A replay can return to an earlier request. Dismissal applies only until the // displayed request changes, even when that next key was previously visited. if (request.key !== openRequestKey) setRequest({ key: openRequestKey, dismissed: false }); const open = manuallyOpen || (openRequestKey !== undefined && !request.dismissed); const [maximized, setMaximized] = useState(false); const [bounds, setBounds] = useState(() => fitWindow({ x: (window.innerWidth - 800) / 2, y: 64, width: 800, height: 560 })); const [viewport, setViewport] = useState({ width: window.innerWidth, height: window.innerHeight }); const order = useSyncExternalStore(subscribeFloatingWindows, floatingWindowOrder); const trigger = useRef(null); const heading = useRef(null); const focusOnOpen = useRef(false); const raise = () => raiseFloatingWindow(id); const close = () => { setManuallyOpen(false); setRequest({ key: openRequestKey, dismissed: true }); removeFloatingWindow(id); if (heading.current?.closest('[data-floating-window]')?.contains(document.activeElement)) trigger.current?.focus({ preventScroll: true }); }; useEffect(() => () => removeFloatingWindow(id), [id]); useLayoutEffect(() => { if (!open) return; const others = floatingWindowOrder().filter((windowId) => windowId !== id); const headingHeight = Array.from(floatingWindowHost().querySelectorAll(".floating-window-heading")) .reduce((height, element) => Math.max(height, element.offsetHeight), 48); const step = headingHeight + 8; const minHeight = Math.min(260, window.innerHeight - 16); const slots = Math.max(1, Math.floor((window.innerHeight - 64 - minHeight - 8) / step) + 1); const slot = others.length % slots; const y = Math.max(8, Math.min(64 + slot * step, window.innerHeight - minHeight - 8)); // Keep the preceding headings exposed. Shorter viewports reduce content // height instead of clamping several windows onto the same title bar. // Absolute placement also makes StrictMode's repeated setup idempotent. setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + slot * 24, y, width: 800, height: Math.min(560, window.innerHeight - y - 8) })); raiseFloatingWindow(id); if (focusOnOpen.current) heading.current?.focus({ preventScroll: true }); focusOnOpen.current = false; const resize = () => { setViewport({ width: window.innerWidth, height: window.innerHeight }); setBounds((current) => { const minimumHeight = Math.min(260, window.innerHeight - 16); const y = Math.max(8, Math.min(current.y, window.innerHeight - minimumHeight - 8)); return fitWindow({ ...current, y, height: Math.min(current.height, window.innerHeight - y - 8) }); }); }; window.addEventListener("resize", resize); return () => { window.removeEventListener("resize", resize); removeFloatingWindow(id); }; }, [id, open]); const current = maximized ? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 } : bounds; return <> {open ? createPortal( setBounds((previous) => fitWindow({ ...previous, x: position.x, y: position.y }))} onResizeStop={(_, __, element, ___, position) => setBounds(fitWindow({ ...position, width: element.offsetWidth, height: element.offsetHeight }))} style={{ zIndex: order.indexOf(id) + 1, display: "flex", flexDirection: "column" }} resizeHandleClasses={{ bottomRight: "floating-window-resize" }} resizeHandleStyles={{ top: { top: 0, height: 8 }, right: { right: 0, width: 8 }, bottom: { bottom: 0, height: 8 }, left: { left: 0, width: 8 }, topRight: { top: 0, right: 0, width: 18, height: 18 }, topLeft: { top: 0, left: 0, width: 18, height: 18 }, bottomLeft: { bottom: 0, left: 0, width: 18, height: 18 }, bottomRight: { right: 0, bottom: 0, width: 18, height: 18 } }} resizeHandleComponent={{ bottomRight: }} className="workbench-shadcn-surface pointer-events-auto flex flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg" role="dialog" aria-modal="false" aria-labelledby={titleId} data-floating-window="" onPointerDownCapture={raise} onFocusCapture={raise} onKeyDown={(event: React.KeyboardEvent) => { if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); } }}>
{ if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return; event.preventDefault(); const dx = event.key === "ArrowLeft" ? -16 : event.key === "ArrowRight" ? 16 : 0; const dy = event.key === "ArrowUp" ? -16 : event.key === "ArrowDown" ? 16 : 0; setBounds((previous) => fitWindow(event.shiftKey ? { ...previous, width: Math.max(320, previous.width + dx), height: Math.max(260, previous.height + dy) } : { ...previous, x: previous.x + dx, y: previous.y + dy })); }}>

{title}

{children({ width: current.width - 26, height: current.height - 76 })}
, floatingWindowHost()) : null} ; }