"use client" /** * FloatingWindow — non-modal, movable, resizable tool window. * * Deliberately **not** a `` / `showModal()`: the page behind stays live * (work-alongside). Portal to `document.body` at `z-[55]` so the window paints * *above* the utility bar (`z-50`). Grip tooltips use `z-[70]` so they still * clear this chrome. Do not globally raise every Radix overlay: force-mounted * popovers would cover the page. * * Chrome matches the DS floating-sheet contract (`FLOATING_SHEET_CHROME_CLASS`) * with freer `rounded-2xl` corners (all four visible when floating). * * Keyboard parity (P6): grip is focusable — arrows move, Alt+arrows resize, * Shift takes coarse steps; click / activate snaps to the next corner. * * @see lib/floating-window.ts — rect clamping shared by drag, resize, and keys */ import * as React from "react" import { createPortal } from "react-dom" import { useIsClient } from "../../hooks/use-is-client" import { FLOATING_SHEET_CHROME_CLASS } from "../../lib/floating-sheet-panel" import { FLOATING_WINDOW_MIN_HEIGHT, FLOATING_WINDOW_MIN_WIDTH, FLOATING_WINDOW_NUDGE, FLOATING_WINDOW_NUDGE_COARSE, clampFloatingWindowRect, defaultFloatingWindowRect, moveFloatingWindowRect, nudgeFloatingWindowSize, readFloatingWindowViewport, resizeFloatingWindowRect, snapFloatingWindowToNextCorner, FLOATING_WINDOW_Z_INDEX, type FloatingWindowRect, type FloatingWindowResizeHandle, type FloatingWindowSizeOptions, type FloatingWindowViewport, } from "../../lib/floating-window" import { cn } from "../../lib/utils" import { FloatingWindowOverlayProvider } from "./floating-window-overlay-context" import { Tooltip, TooltipContent, TooltipTrigger, } from "./tooltip" export type FloatingWindowProps = { /** When false, nothing is portaled. Default true. */ open?: boolean /** Keeps the tree mounted but hides + unfocuses (e.g. minimize). */ hidden?: boolean /** * Stored rect, or `null` to use `defaultRect`. Clamped against the live * viewport on every render so a laptop never inherits a 32" position. */ rect: FloatingWindowRect | null onRectChange: (rect: FloatingWindowRect) => void /** Factory when `rect` is null. Defaults to bottom-right. */ defaultRect?: (viewport: FloatingWindowViewport) => FloatingWindowRect minWidth?: number minHeight?: number inset?: number /** * Override the top clamp. Defaults to `inset` (viewport edge). The window * may overlap the utility bar; stacking (not geometry) keeps it on top. */ insetTop?: number /** Leading identity / title block (next to the grip). */ title: React.ReactNode /** * Double-click on the title region. A pointer shortcut only: whatever it * opens MUST also be reachable from a menu or button, or keyboard users lose * the action entirely (P6). */ onTitleDoubleClick?: React.MouseEventHandler /** Trailing header actions (before optional built-in chrome). */ toolbar?: React.ReactNode children: React.ReactNode "aria-label": string dataSlot?: string className?: string style?: React.CSSProperties /** Esc when focus is inside. Non-modal — does not steal page Esc. */ onEscape?: () => void gripAriaLabel?: string gripTooltip?: string /** * When true (default), a grip click (without drag) cycles corner snap. * Set false for free-move windows that only clamp to the viewport. */ cornerSnap?: boolean /** Portal target; defaults to `document.body`. */ container?: Element | DocumentFragment | null } const RESIZE_TINT = "transition-colors hover:bg-interactive-hover-foreground/50 active:bg-muted-foreground/70" const RESIZE_HANDLES: { handle: FloatingWindowResizeHandle label: string className: string }[] = [ { handle: "n", label: "Resize from top edge", className: cn("inset-x-3 top-0 h-1.5 cursor-ns-resize", RESIZE_TINT), }, { handle: "s", label: "Resize from bottom edge", className: cn("inset-x-3 bottom-0 h-1.5 cursor-ns-resize", RESIZE_TINT), }, { handle: "w", label: "Resize from left edge", className: cn("inset-y-3 left-0 w-1.5 cursor-ew-resize", RESIZE_TINT), }, { handle: "e", label: "Resize from right edge", className: cn("inset-y-3 right-0 w-1.5 cursor-ew-resize", RESIZE_TINT), }, { handle: "nw", label: "Resize from top-left corner", className: "left-0 top-0 size-5 cursor-nwse-resize", }, { handle: "ne", label: "Resize from top-right corner", className: "right-0 top-0 size-5 cursor-nesw-resize", }, { handle: "sw", label: "Resize from bottom-left corner", className: "bottom-0 left-0 size-5 cursor-nesw-resize", }, { handle: "se", label: "Resize from bottom-right corner", className: "bottom-0 right-0 size-5 cursor-nwse-resize", }, ] function isInteractiveTarget(target: EventTarget | null): boolean { return ( target instanceof Element && target.closest("button, a, input, textarea, select, [role='menuitem']") !== null ) } function useFloatingWindowViewport(): FloatingWindowViewport { const [viewport, setViewport] = React.useState(readFloatingWindowViewport) React.useEffect(() => { const onResize = () => setViewport(readFloatingWindowViewport()) window.addEventListener("resize", onResize, { passive: true }) return () => window.removeEventListener("resize", onResize) }, []) return viewport } export function FloatingWindow({ open = true, hidden = false, rect: storedRect, onRectChange, defaultRect, minWidth = FLOATING_WINDOW_MIN_WIDTH, minHeight = FLOATING_WINDOW_MIN_HEIGHT, inset, insetTop: insetTopProp, title, onTitleDoubleClick, toolbar, children, "aria-label": ariaLabel, dataSlot = "floating-window", className, style, onEscape, gripAriaLabel = "Move window to the next corner. Arrow keys move it, Alt with arrow keys resizes it.", gripTooltip = "Drag to move · click for the next corner · arrows nudge · Alt + arrows resize", cornerSnap = true, container, }: FloatingWindowProps) { const isClient = useIsClient() const viewport = useFloatingWindowViewport() const sizeOptions = React.useMemo( () => ({ minWidth, minHeight, inset, insetTop: insetTopProp }), [minWidth, minHeight, inset, insetTopProp], ) const resolveDefault = React.useCallback( (vp: FloatingWindowViewport) => defaultRect ? defaultRect(vp) : defaultFloatingWindowRect(vp, sizeOptions), [defaultRect, sizeOptions], ) const [dragging, setDragging] = React.useState<"move" | "resize" | null>(null) const draggedRef = React.useRef(false) const releaseGestureRef = React.useRef<(() => void) | null>(null) React.useEffect(() => () => releaseGestureRef.current?.(), []) const rect = React.useMemo( () => storedRect ? clampFloatingWindowRect(storedRect, viewport, sizeOptions) : resolveDefault(viewport), [storedRect, viewport, sizeOptions, resolveDefault], ) const rectRef = React.useRef(rect) const viewportRef = React.useRef(viewport) React.useEffect(() => { rectRef.current = rect viewportRef.current = viewport }) const startPointerGesture = React.useCallback( ( event: React.PointerEvent, apply: ( start: FloatingWindowRect, dx: number, dy: number, ) => FloatingWindowRect, kind: "move" | "resize", ) => { if (event.button !== 0) return // Portals keep the React tree of the opener (e.g. settings mounted from // Ask Leo's toolbar). Without stopPropagation, move/resize bubbles to the // parent window and both rects change together. event.stopPropagation() event.preventDefault() const startRect = rectRef.current const startX = event.clientX const startY = event.clientY draggedRef.current = false setDragging(kind) const onMove = (ev: PointerEvent) => { const dx = ev.clientX - startX const dy = ev.clientY - startY if (Math.abs(dx) > 3 || Math.abs(dy) > 3) draggedRef.current = true onRectChange(apply(startRect, dx, dy)) } const release = () => { releaseGestureRef.current = null setDragging(null) window.removeEventListener("pointermove", onMove) window.removeEventListener("pointerup", release) window.removeEventListener("pointercancel", release) } releaseGestureRef.current = release window.addEventListener("pointermove", onMove) window.addEventListener("pointerup", release) window.addEventListener("pointercancel", release) }, [onRectChange], ) const startMoveGesture = React.useCallback( (event: React.PointerEvent) => { startPointerGesture( event, (start, dx, dy) => moveFloatingWindowRect(start, dx, dy, viewportRef.current, sizeOptions), "move", ) }, [startPointerGesture, sizeOptions], ) const startMoveFromTitleBar = React.useCallback( (event: React.PointerEvent) => { if (isInteractiveTarget(event.target)) return startMoveGesture(event) }, [startMoveGesture], ) const startResize = React.useCallback( (event: React.PointerEvent, handle: FloatingWindowResizeHandle) => { startPointerGesture( event, (start, dx, dy) => resizeFloatingWindowRect( start, handle, dx, dy, viewportRef.current, sizeOptions, ), "resize", ) }, [startPointerGesture, sizeOptions], ) const snapToNextCorner = React.useCallback( (event?: React.MouseEvent) => { event?.stopPropagation() if (!cornerSnap) { draggedRef.current = false return } if (draggedRef.current) { draggedRef.current = false return } onRectChange( snapFloatingWindowToNextCorner( rectRef.current, viewportRef.current, sizeOptions, ), ) }, [cornerSnap, onRectChange, sizeOptions], ) const onGripKeyDown = React.useCallback( (event: React.KeyboardEvent) => { const step = event.shiftKey ? FLOATING_WINDOW_NUDGE_COARSE : FLOATING_WINDOW_NUDGE const axis: Record = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0], } const delta = axis[event.key] if (!delta) return event.preventDefault() const [dx, dy] = delta onRectChange( event.altKey ? nudgeFloatingWindowSize( rectRef.current, dx, dy, viewportRef.current, sizeOptions, ) : moveFloatingWindowRect( rectRef.current, dx, dy, viewportRef.current, sizeOptions, ), ) }, [onRectChange, sizeOptions], ) const onKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Escape" || !onEscape) return event.stopPropagation() onEscape() }, [onEscape], ) // Portal only after the client store has committed — avoids a null tree during // SSR / first paint, and recovers cleanly when a stuck HMR pass remounts. if (!open || !isClient) return null const portalTarget = container ?? document.body return createPortal(