import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, } from 'react'; import { AlignLeft, AlignRight, Maximize2, Minus, Plus } from 'lucide-react'; import { cn } from '@/lib/utils'; /** Edge anchor used to place a window on first run (before the user drags it). */ export type FloatAnchor = | 'top-left' | 'top-center' | 'top-right' | 'right' | 'bottom-left' | 'bottom-center' | 'bottom-right'; /** A docked column edge: pinned to a screen edge and filling the viewport height. */ export type FloatDock = 'left' | 'right'; /** A dock mode: a docked edge, or `'float'` (free-floating, the default). */ export type FloatDockMode = FloatDock | 'float'; const STORAGE_PREFIX = 'history.floatwin.v1.'; // Module-level z-index bookkeeping so sibling windows stack in click order: // every window starts at the base (10); each time a *different* window is // raised, the shared counter advances, so the newest on top always wins. const Z_BASE = 10; const Z_STEP = 10; // A pinned window always floats above every docked/floating window, no matter // how many times those are raised. const Z_PINNED = 100000; let zSeq = 0; let zTopId: string | null = null; /** Returns the new z-index to use, or -1 if this window is already front-most. */ function nextZ(id: string): number { if (zTopId === id) return -1; zSeq += 1; zTopId = id; return Z_BASE + zSeq * Z_STEP; } interface Pos { x: number; y: number; } /** Read a persisted position; any missing/corrupt storage falls back to null. */ function loadPos(id: string): Pos | null { try { const raw = localStorage.getItem(STORAGE_PREFIX + 'pos.' + id); if (raw == null) return null; const v = JSON.parse(raw) as Partial; if ( typeof v.x !== 'number' || !Number.isFinite(v.x) || typeof v.y !== 'number' || !Number.isFinite(v.y) ) { return null; } return { x: v.x, y: v.y }; } catch { return null; // storage unavailable or unparseable — first-run placement } } function savePos(id: string, x: number, y: number): void { try { localStorage.setItem(STORAGE_PREFIX + 'pos.' + id, JSON.stringify({ x, y })); } catch { /* storage unavailable (e.g. private mode) — dragging still works, just won't persist */ } } const EDGE = 12; /** Resolve an anchor to a concrete top-left position for a window of w×h. */ function anchorToPos(anchor: FloatAnchor, w: number, h: number): Pos { const vw = window.innerWidth; const vh = window.innerHeight; switch (anchor) { case 'top-left': return { x: EDGE, y: 72 }; case 'top-center': return { x: Math.round((vw - w) / 2), y: 8 }; case 'top-right': case 'right': return { x: vw - w - EDGE, y: 72 }; case 'bottom-left': return { x: EDGE, y: Math.max(EDGE, vh - h - EDGE) }; case 'bottom-center': return { x: Math.round((vw - w) / 2), y: Math.max(64, vh - h - EDGE) }; case 'bottom-right': return { x: vw - w - EDGE, y: Math.max(64, vh - h - EDGE) }; } } /** * Clamp a top-left position so the window can never be dragged fully * off-screen: at least MIN_W px of width and MIN_H px (the title bar) of * height always remain inside the viewport. */ function clampPos(x: number, y: number, w: number, h: number): Pos { const vw = window.innerWidth; const vh = window.innerHeight; const MIN_W = 96; const MIN_H = 36; let minX = MIN_W - w; let maxX = vw - MIN_W; if (minX > maxX) { minX = maxX = Math.round((minX + maxX) / 2); } let minY = MIN_H - h; let maxY = vh - MIN_H; if (minY > maxY) { minY = maxY = Math.round((minY + maxY) / 2); } return { x: Math.min(Math.max(x, minX), maxX), y: Math.min(Math.max(y, minY), maxY) }; } export interface FloatingWindowProps { /** Stable id — used for the localStorage key and as a data-attribute. */ id: string; /** Optional `data-testid` on the window root, for e2e targeting. */ dataTestId?: string; /** * First-run placement for a free-floating window; the last dragged position * (persisted) takes over. Ignored when `pinned` or `dock` is set. */ anchor?: FloatAnchor; /** * Pin the window to the top-center of the viewport: a fixed, non-movable bar * a few px from the top that re-centers automatically on resize, always * floats above docked/floating panels, and never reads/writes a persisted * position. Used for the slim header. */ pinned?: boolean; /** * Dock the window into a full-height left or right column: non-movable, * body scrolls, never reads/writes the persisted free-floating position. * Absent (`undefined`) = free-floating as before. */ dock?: FloatDock; /** * When provided, render dock controls (left / right / float) in the title bar * and report the chosen mode back to the parent, which persists it. */ onDockChange?: (mode: FloatDockMode) => void; /** Fixed window width in px (content is sized to it). Omit to fit content. */ width?: number; /** Extra px nudged away from the anchor position (e.g. clear the map attribution). */ offset?: { x?: number; y?: number }; /** Title-bar content (text or node). The whole bar is the drag handle. */ title?: ReactNode; /** Optional icon before the title. */ titleIcon?: ReactNode; /** Right-side title-bar content, rendered before the dock/collapse controls. */ titleExtra?: ReactNode; /** Render the collapse toggle (default true). */ collapsible?: boolean; /** Controlled collapsed state — the parent persists/restores it. */ collapsed?: boolean; onCollapsedChange?: (collapsed: boolean) => void; /** Extra classes for the inner card (border/background). */ className?: string; /** Extra classes for the body wrapper (max-height, scrolling, …). */ bodyClassName?: string; children?: ReactNode; } /** * A window that floats over the full-screen map. Three mutually-exclusive * modes, set via props (absent = free-floating): * • **free-floating** (default) — draggable by the title bar (pointer * capture), clamped on-screen, position persisted in localStorage, raised * above siblings on click, collapsible. * • **pinned** — a fixed, non-movable bar centered at the top of the * viewport a few px down; re-centers on resize; always above other * windows; no position persisted. * • **docked** (`left`/`right`) — a non-movable, full-height column pinned * to a screen edge whose body scrolls; the persisted free-floating * position is neither read nor written while docked. * When `onDockChange` is provided, small dock/left/right/float controls render * in the title bar and report the chosen mode back to the parent. */ export default function FloatingWindow({ id, dataTestId, anchor = 'top-center', pinned = false, dock, onDockChange, width, offset, title, titleIcon, titleExtra, collapsible: collapsibleProp = true, collapsed = false, onCollapsedChange, className, bodyClassName, children, }: FloatingWindowProps) { const isPinned = pinned; const isDocked = dock === 'left' || dock === 'right'; // Free-floating is the only mode that drags, persists a position, and needs // viewport measurement/clamping. Pinned and docked are CSS-positioned. const isFloating = !isPinned && !isDocked; const rootRef = useRef(null); const sizeRef = useRef<{ w: number; h: number } | null>(null); const dragRef = useRef<{ dx: number; dy: number; pointerId: number } | null>(null); const posRef = useRef(null); const [pos, setPosState] = useState(null); const [z, setZ] = useState(Z_BASE); const setPos = (p: Pos): void => { posRef.current = p; setPosState(p); }; // First paint (free-floating only): measure, then place (restored position // or anchor) and clamp. Runs before paint (useLayoutEffect), so no flash. // Re-runs when the mode changes so a window returning from docked re-places. useLayoutEffect(() => { if (!isFloating) return; if (posRef.current) return; const el = rootRef.current; if (!el) return; const rect = el.getBoundingClientRect(); if (rect.width <= 0) return; sizeRef.current = { w: rect.width, h: rect.height }; const base = loadPos(id) ?? anchorToPos(anchor, rect.width, rect.height); const p = clampPos( base.x + (offset?.x ?? 0), base.y + (offset?.y ?? 0), rect.width, rect.height, ); posRef.current = p; setPosState(p); }, [id, anchor, offset, isFloating]); // Re-measure + re-clamp whenever the body toggles hidden (expand can push a // bottom-anchored window off the bottom edge). Free-floating only. useLayoutEffect(() => { if (!isFloating) return; const el = rootRef.current; const p = posRef.current; if (!el || !p) return; const rect = el.getBoundingClientRect(); if (rect.width <= 0) return; sizeRef.current = { w: rect.width, h: rect.height }; const c = clampPos(p.x, p.y, rect.width, rect.height); if (c.x !== p.x || c.y !== p.y) setPos(c); }, [collapsed, isFloating]); // Keep a free-floating window on-screen when the viewport shrinks. useEffect(() => { if (!isFloating) return; const onResize = () => { const p = posRef.current; const s = sizeRef.current; if (!p || !s) return; const c = clampPos(p.x, p.y, s.w, s.h); if (c.x !== p.x || c.y !== p.y) setPos(c); }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, [isFloating]); const onHandleDown = (e: React.PointerEvent) => { if (e.button !== 0) return; // Never start a drag from an interactive element (buttons, inputs, …). const t = e.target as HTMLElement | null; if (t?.closest('button, a, input, textarea, select, [data-no-drag]')) return; const el = rootRef.current; if (!el) return; const r = el.getBoundingClientRect(); sizeRef.current = { w: r.width, h: r.height }; dragRef.current = { dx: e.clientX - r.left, dy: e.clientY - r.top, pointerId: e.pointerId }; e.currentTarget.setPointerCapture(e.pointerId); }; const onHandleMove = (e: React.PointerEvent) => { const d = dragRef.current; if (!d || d.pointerId !== e.pointerId) return; e.preventDefault(); const s = sizeRef.current ?? { w: 200, h: 100 }; // Fixed positioning: moving this window never reflows the rest of the page. setPos(clampPos(e.clientX - d.dx, e.clientY - d.dy, s.w, s.h)); }; const endDrag = (e: React.PointerEvent) => { const d = dragRef.current; if (!d || d.pointerId !== e.pointerId) return; dragRef.current = null; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* pointer already released */ } const p = posRef.current; if (p) savePos(id, p.x, p.y); }; // Root placement: pinned → centered top bar; docked → full-height edge // column; free-floating → last/initial pixel position (hidden until measured). const rootStyle: CSSProperties = isPinned ? { zIndex: Z_PINNED, top: 8, left: '50%', transform: 'translateX(-50%)' } : isDocked ? { zIndex: z, top: 0, height: '100vh', ...(dock === 'left' ? { left: 0 } : { right: 0 }), } : { zIndex: z, ...(pos ? { left: pos.x, top: pos.y } : {}) }; // The title bar is the drag handle only in free-floating mode. const titleBarHandlers = isFloating ? { onPointerDown: onHandleDown, onPointerMove: onHandleMove, onPointerUp: endDrag, onPointerCancel: endDrag, } : {}; // Docked: the body fills the remaining column height and scrolls, overriding // any max-height the caller set for the floating layout. const bodyClass = isDocked ? cn('min-h-0 flex-1 overflow-y-auto', collapsed && 'hidden') : cn('min-h-0', collapsed && 'hidden', bodyClassName); return (
{ if (isPinned) return; const nz = nextZ(id); if (nz > 0) setZ(nz); }} >
{title != null && (
{titleIcon && ( {titleIcon} )} {title} {titleExtra} {onDockChange && ( )} {collapsibleProp && ( )}
)} {children != null &&
{children}
}
); }