/** * Floating window geometry — pure helpers for drag, resize, keyboard nudge, * and corner snap. Shared by every non-modal floating tool window. * * Clamp is the viewport inset (8px), including over the utility bar. The * window paints *above* that bar (`z-[55]` vs `z-50`); do not use a larger * `insetTop` unless a caller wants to keep a surface out of chrome. * * @see components/ui/floating-window.tsx */ export interface FloatingWindowRect { x: number y: number width: number height: number } export interface FloatingWindowViewport { width: number height: number } export type FloatingWindowResizeHandle = | "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw" export type FloatingWindowCorner = | "bottom-right" | "bottom-left" | "top-left" | "top-right" /** Same inset every DS floating sheet uses (0.5rem). */ export const FLOATING_WINDOW_INSET = 8 /** Above the utility bar (`z-50`); below modal sheets (`z-[80]`). */ export const FLOATING_WINDOW_Z_INDEX = 55 /** Menus, tooltips, and dialogs while a floating window is open. */ export const FLOATING_WINDOW_OVERLAY_Z_INDEX = 70 export const FLOATING_WINDOW_MIN_WIDTH = 320 export const FLOATING_WINDOW_MIN_HEIGHT = 360 export const FLOATING_WINDOW_DEFAULT_WIDTH = 420 export const FLOATING_WINDOW_DEFAULT_HEIGHT = 560 /** Arrow-key step, and the Shift-held step for crossing the screen faster. */ export const FLOATING_WINDOW_NUDGE = 16 export const FLOATING_WINDOW_NUDGE_COARSE = 48 export const FLOATING_WINDOW_CORNERS = [ "bottom-right", "bottom-left", "top-left", "top-right", ] as const export type FloatingWindowSizeOptions = { minWidth?: number minHeight?: number inset?: number /** Top inset, independent of `inset`. Defaults to `inset`. */ insetTop?: number } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), Math.max(min, max)) } function resolveSizeOptions(options?: FloatingWindowSizeOptions) { const inset = options?.inset ?? FLOATING_WINDOW_INSET return { minWidth: options?.minWidth ?? FLOATING_WINDOW_MIN_WIDTH, minHeight: options?.minHeight ?? FLOATING_WINDOW_MIN_HEIGHT, inset, insetTop: options?.insetTop ?? inset, } } /** * Opens bottom-right by default — where a launcher FAB usually sits — so the * window reads as having grown out of the control that opened it. */ export function defaultFloatingWindowRect( viewport: FloatingWindowViewport, options?: FloatingWindowSizeOptions & { width?: number height?: number /** Prefer `"bottom-right"` (default) or `"center-right"`. */ anchor?: "bottom-right" | "center-right" }, ): FloatingWindowRect { const { minWidth, minHeight, inset, insetTop } = resolveSizeOptions(options) const preferredWidth = options?.width ?? FLOATING_WINDOW_DEFAULT_WIDTH const preferredHeight = options?.height ?? FLOATING_WINDOW_DEFAULT_HEIGHT const width = Math.min( preferredWidth, Math.max(minWidth, viewport.width - inset * 2), ) const height = Math.min( preferredHeight, Math.max(minHeight, viewport.height - insetTop - inset), ) const anchor = options?.anchor ?? "bottom-right" const x = Math.max(inset, viewport.width - width - inset - (anchor === "center-right" ? 40 : 0)) const y = anchor === "center-right" ? Math.max(insetTop, Math.round((viewport.height - height) / 2)) : viewport.height - height - inset return clampFloatingWindowRect({ x, y, width, height }, viewport, options) } /** * Keep the whole window inside the viewport inset. Stricter than most desktop * window managers — a half-off-screen window with unreachable handles is a trap. */ export function clampFloatingWindowRect( rect: FloatingWindowRect, viewport: FloatingWindowViewport, options?: FloatingWindowSizeOptions, ): FloatingWindowRect { const { minWidth, minHeight, inset, insetTop } = resolveSizeOptions(options) const width = clamp( rect.width, minWidth, Math.max(minWidth, viewport.width - inset * 2), ) const height = clamp( rect.height, minHeight, Math.max(minHeight, viewport.height - insetTop - inset), ) return { width, height, x: clamp(rect.x, inset, viewport.width - width - inset), y: clamp(rect.y, insetTop, viewport.height - height - inset), } } export function moveFloatingWindowRect( rect: FloatingWindowRect, dx: number, dy: number, viewport: FloatingWindowViewport, options?: FloatingWindowSizeOptions, ): FloatingWindowRect { return clampFloatingWindowRect( { ...rect, x: rect.x + dx, y: rect.y + dy }, viewport, options, ) } /** * Resize from one of eight handles. `start` is the rect when the gesture began; * deltas are measured from that instant so dragging past a clamp and back does * not accumulate discarded pixels. */ export function resizeFloatingWindowRect( start: FloatingWindowRect, handle: FloatingWindowResizeHandle, dx: number, dy: number, viewport: FloatingWindowViewport, options?: FloatingWindowSizeOptions, ): FloatingWindowRect { const { minWidth, minHeight, inset, insetTop } = resolveSizeOptions(options) let { x, y, width, height } = start if (handle.includes("e")) { width = clamp( start.width + dx, minWidth, viewport.width - inset - start.x, ) } if (handle.includes("w")) { const right = start.x + start.width width = clamp(start.width - dx, minWidth, right - inset) x = right - width } if (handle.includes("s")) { height = clamp( start.height + dy, minHeight, viewport.height - inset - start.y, ) } if (handle.includes("n")) { const bottom = start.y + start.height height = clamp(start.height - dy, minHeight, bottom - insetTop) y = bottom - height } return clampFloatingWindowRect({ x, y, width, height }, viewport, options) } function cornerOf( rect: FloatingWindowRect, viewport: FloatingWindowViewport, inset: number, insetTop: number, ): FloatingWindowCorner | null { const near = (a: number, b: number) => Math.abs(a - b) < 2 const left = near(rect.x, inset) const top = near(rect.y, insetTop) const right = near(rect.x + rect.width, viewport.width - inset) const bottom = near(rect.y + rect.height, viewport.height - inset) if (bottom && right) return "bottom-right" if (bottom && left) return "bottom-left" if (top && left) return "top-left" if (top && right) return "top-right" return null } export function snapFloatingWindowRect( rect: FloatingWindowRect, corner: FloatingWindowCorner, viewport: FloatingWindowViewport, options?: FloatingWindowSizeOptions, ): FloatingWindowRect { const { inset, insetTop } = resolveSizeOptions(options) const right = corner === "bottom-right" || corner === "top-right" const bottom = corner === "bottom-right" || corner === "bottom-left" return clampFloatingWindowRect( { ...rect, x: right ? viewport.width - rect.width - inset : inset, y: bottom ? viewport.height - rect.height - inset : insetTop, }, viewport, options, ) } /** Park in the next corner, keeping size. */ export function snapFloatingWindowToNextCorner( rect: FloatingWindowRect, viewport: FloatingWindowViewport, options?: FloatingWindowSizeOptions, ): FloatingWindowRect { const { inset, insetTop } = resolveSizeOptions(options) const current = cornerOf(rect, viewport, inset, insetTop) const index = current ? FLOATING_WINDOW_CORNERS.indexOf(current) : -1 const next = FLOATING_WINDOW_CORNERS[(index + 1) % FLOATING_WINDOW_CORNERS.length]! return snapFloatingWindowRect(rect, next, viewport, options) } /** Keyboard resize from the bottom-right corner (top-left stays put). */ export function nudgeFloatingWindowSize( rect: FloatingWindowRect, dx: number, dy: number, viewport: FloatingWindowViewport, options?: FloatingWindowSizeOptions, ): FloatingWindowRect { return resizeFloatingWindowRect(rect, "se", dx, dy, viewport, options) } export function readFloatingWindowViewport(): FloatingWindowViewport { if (typeof window === "undefined") return { width: 1280, height: 800 } return { width: window.innerWidth, height: window.innerHeight } }