/** * A draggable, resizable, floating window. * * NOTE: there is copious use of flushSync; see TODOs. We will need to remove * this. (We can build our own windowing from scratch, sans `react-draggable` * and `re-resizable`; but I don't want to do that until it's worth the effort.) */ import { OpenInNewWindowIcon } from "@radix-ui/react-icons"; import { ReloadIcon } from "@radix-ui/react-icons"; import { Resizable } from "re-resizable"; import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { flushSync } from "react-dom"; import Draggable from "react-draggable"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/util/utils"; import { PowerIcon } from "./icons/PowerIcon"; type OS = "Windows" | "macOS" | "Linux" | "Unknown"; const Constants = { HandleSize: "40px", MinHeight: 52, MinWidth: 256, } as const; function MacOsButton(props: { color: string; children?: React.ReactNode; disabled?: boolean; tip?: string; // -- onClick: () => void; }) { return ( ); } function WindowsButton(props: { children?: React.ReactNode; disabled?: boolean; tip?: string; // -- onClick: () => void; }) { return ( ); } /** * Button to open browser in a new tab. */ function BreakoutButton(props: { onClick: () => void }) { return ( Open In New Tab ); } function PowerButton(props: { onClick: () => void }) { return ( Cycle (New Browser) ); } function ReloadButton(props: { isReloading: boolean; onClick: () => void }) { return ( Reconnect ); } function getOs(): OS { if (typeof navigator === "undefined") { return "Unknown"; // For non-browser environments } const platform = navigator.platform.toLowerCase(); const userAgent = navigator.userAgent.toLowerCase(); if (platform.includes("win") || userAgent.includes("windows")) { return "Windows"; } if (platform.includes("mac") || userAgent.includes("mac os")) { return "macOS"; } if ( platform.includes("linux") || userAgent.includes("linux") || userAgent.includes("x11") ) { return "Linux"; } return "Unknown"; } function FloatingWindow({ bounded, children, initialPosition, initialWidth, initialHeight, maximized, showBreakoutButton, showCloseButton, showMaximizeButton, showMinimizeButton, showPowerButton, showReloadButton = false, title, zIndex, // -- onBlur, onBreakout, onCycle, onFocus, onInteract, onMinimize, onMaximize, onRestore, }: { bounded?: boolean; children: React.ReactNode; initialHeight?: number; initialPosition?: { x: number; y: number }; initialWidth?: number; maximized?: boolean; showBreakoutButton?: boolean; showCloseButton?: boolean; showMaximizeButton?: boolean; showMinimizeButton?: boolean; showPowerButton?: boolean; showReloadButton?: boolean; title: string; zIndex?: number; // -- onBlur?: () => void; onBreakout?: () => void; onCycle?: () => void; onFocus?: () => void; onInteract?: () => void; onMinimize?: () => void; onMaximize?: () => void; onRestore?: () => void; }) { const [reloadKey, setReloadKey] = useState(0); const [isReloading, setIsReloading] = useState(false); const [position, setPosition] = useState(initialPosition ?? { x: 0, y: 0 }); const [size, setSize] = useState({ left: initialPosition?.x ?? 0, top: initialPosition?.y ?? 0, height: initialHeight ?? Constants.MinHeight, width: initialWidth ?? Constants.MinWidth, }); const [lastSize, setLastSize] = useState({ left: initialPosition?.x ?? 0, top: initialPosition?.y ?? 0, height: initialHeight ?? Constants.MinHeight, width: initialWidth ?? Constants.MinWidth, }); const [restoreSize, setRestoreSize] = useState({ left: initialPosition?.x ?? 0, top: initialPosition?.y ?? 0, height: initialHeight ?? Constants.MinHeight, width: initialWidth ?? Constants.MinWidth, }); const [minimizedPosition, setMinimizedPosition] = useState<{ x: number; y: number; } | null>(null); const [isMaximized, setIsMaximized] = useState(maximized ?? false); const [isMinimized, setIsMinimized] = useState(false); const parentRef = useRef(null); const resizableRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [dragStartSize, setDragStartSize] = useState< | { left: number; top: number; width: number; height: number; } | undefined >(undefined); const hasInitialized = useRef(false); const os = getOs(); const onResize = useCallback( ({ delta, direction, size, }: { delta: { width: number; height: number }; direction: string; size: { left: number; top: number; width: number; height: number }; }) => { if (!dragStartSize) { return; } const top = resizableRef.current?.parentElement?.offsetTop ?? lastSize.top; const left = resizableRef.current?.parentElement?.offsetLeft ?? lastSize.left; const width = resizableRef.current?.parentElement?.offsetWidth ?? lastSize.width; const height = resizableRef.current?.parentElement?.offsetHeight ?? lastSize.height; setLastSize({ top, left, width, height }); const directions = ["top", "left", "topLeft", "bottomLeft", "topRight"]; if (directions.indexOf(direction) !== -1) { let newLeft = size.left; let newTop = size.top; if (direction === "bottomLeft") { newLeft = dragStartSize.left - delta.width; } else if (direction === "topRight") { newTop = dragStartSize.top - delta.height; } else { newLeft = dragStartSize.left - delta.width; newTop = dragStartSize.top - delta.height; } // TODO(follow-up): https://github.com/bokuweb/re-resizable/issues/868 flushSync(() => { setSize({ ...size, left: newLeft, top: newTop, }); setPosition({ x: newLeft, y: newTop }); }); } else { flushSync(() => { setSize({ ...size, left: size.left, top: size.top, }); setPosition({ x: size.left, y: size.top }); }); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [dragStartSize], ); useEffect(() => { if (hasInitialized.current || !initialWidth || !initialHeight) { return; } hasInitialized.current = true; setSize({ left: initialPosition?.x ?? 0, top: initialPosition?.y ?? 0, width: initialWidth, height: initialHeight, }); setPosition({ x: initialPosition?.x ?? 0, y: initialPosition?.y ?? 0 }); }, [initialWidth, initialHeight, initialPosition]); /** * Forces the sizing to take place after the resize is complete. * * TODO(jdo): emits warnings in the dev console. ref: https://github.com/bokuweb/re-resizable/issues/868 */ useEffect(() => { if (isResizing) { return; } const width = lastSize.width; const height = lastSize.height; flushSync(() => { setSize({ ...size, width, height, }); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isResizing]); const onDrag = (position: { x: number; y: number }) => { if (isMaximized) { restore(); return; } if (isMinimized) { setMinimizedPosition({ x: position.x, y: position.y }); } setPosition({ x: position.x, y: position.y }); setSize({ ...size, left: position.x, top: position.y, }); setLastSize({ ...size, left: position.x, top: position.y, }); }; const toggleMaximized = () => { if (!isMaximized) { maximize(); } else { restore(); } setIsMinimized(false); }; const toggleMinimized = () => { if (!isMinimized) { minimize(); } else { restore(); } setIsMaximized(false); }; const maximize = () => { const parent = parentRef.current; if (!parent) { console.warn("No parent - cannot maximize."); return; } if (!isMinimized) { setRestoreSize({ ...size, left: position.x, top: position.y, }); } setIsMaximized(true); setIsMinimized(false); setSize({ left: 0, top: 0, // has to take into account padding...hack width: parent.offsetWidth - 16, height: parent.offsetHeight - 16, }); setPosition({ x: 0, y: 0 }); onMaximize?.(); }; const minimize = () => { const parent = parentRef.current; if (!parent) { console.warn("No parent - cannot minimize."); return; } if (!isMaximized) { setRestoreSize({ ...size, left: position.x, top: position.y, }); } setIsMaximized(false); setIsMinimized(true); const defaultLeft = 0; const parentBottom = parentRef.current?.offsetHeight; const defaultTop = parentBottom - Constants.MinHeight - 16; const left = minimizedPosition?.x ?? defaultLeft; const top = minimizedPosition?.y ?? defaultTop; setSize({ left, top, width: Constants.MinWidth, height: Constants.MinHeight, }); setPosition({ x: left, y: top }); onMinimize?.(); }; const restore = () => { const position = isDragging ? { left: 0, top: 0 } : { left: restoreSize.left, top: restoreSize.top, }; setSize({ left: position.left, top: position.top, width: restoreSize.width, height: restoreSize.height, }); setPosition({ x: position.left, y: position.top }); setIsMaximized(false); setIsMinimized(false); onRestore?.(); }; const reload = () => { if (isReloading) { return; } setReloadKey((prev) => prev + 1); setIsReloading(true); setTimeout(() => { setIsReloading(false); }, 1000); }; const breakout = () => { onBreakout?.(); }; const cycle = () => { onCycle?.(); }; /** * If maximized, need to retain max size during parent resizing. */ useLayoutEffect(() => { const observer = new ResizeObserver(() => { const parent = parentRef.current; if (!parent) { return; } if (isMaximized) { setSize({ left: 0, top: 0, // has to take into account padding...hack width: parent.offsetWidth - 16, height: parent.offsetHeight - 16, }); } }); parentRef.current && observer.observe(parentRef.current); return () => { observer.disconnect(); }; }, [isMaximized]); return (
setIsDragging(true)} onDrag={(_, data) => onDrag(data)} onStop={() => setIsDragging(false)} bounds={bounded ?? true ? "parent" : undefined} disabled={isResizing} > { if (isMaximized) { return; } onFocus?.(); setIsMinimized(false); setIsResizing(true); setDragStartSize({ ...size, left: position.x, top: position.y }); }} onResize={(_, direction, __, delta) => { if (isMaximized) { return; } onFocus?.(); onResize({ delta, direction, size }); }} onResizeStop={() => { if (isMaximized) { return; } setIsResizing(false); setDragStartSize(undefined); }} defaultSize={size} size={size} >
{ onInteract?.(); e.currentTarget.focus(); }} onDoubleClick={() => { toggleMaximized(); }} >
{os === "macOS" ? ( <>
{showCloseButton && ( {}} /> )} {showMinimizeButton && ( )} {showMaximizeButton && ( )} {showPowerButton && cycle()} />}
{showBreakoutButton && ( breakout()} /> )} {title}
{showReloadButton && ( reload()} /> )} ) : ( <> {showReloadButton && ( reload()} /> )}
{title}
{showPowerButton && cycle()} />} {showMinimizeButton && (
)} {showMaximizeButton && (
)}
)}
{children}
); } export { BreakoutButton, FloatingWindow, PowerButton, ReloadButton };