447 lines
16 KiB
TypeScript
447 lines
16 KiB
TypeScript
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<Pos>;
|
||
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<HTMLDivElement>(null);
|
||
const sizeRef = useRef<{ w: number; h: number } | null>(null);
|
||
const dragRef = useRef<{ dx: number; dy: number; pointerId: number } | null>(null);
|
||
const posRef = useRef<Pos | null>(null);
|
||
const [pos, setPosState] = useState<Pos | null>(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<HTMLDivElement>) => {
|
||
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<HTMLDivElement>) => {
|
||
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<HTMLDivElement>) => {
|
||
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 (
|
||
<div
|
||
ref={rootRef}
|
||
data-float-window={id}
|
||
data-testid={dataTestId}
|
||
style={rootStyle}
|
||
className={cn('fixed', isFloating && !pos && 'pointer-events-none opacity-0')}
|
||
onPointerDown={() => {
|
||
if (isPinned) return;
|
||
const nz = nextZ(id);
|
||
if (nz > 0) setZ(nz);
|
||
}}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'overflow-hidden rounded-xl border border-border/70 bg-slate-900/80 shadow-2xl shadow-black/50 backdrop-blur-md',
|
||
isDocked && 'flex h-full flex-col',
|
||
className,
|
||
)}
|
||
style={
|
||
isDocked
|
||
? { width: width ?? 'fit-content', maxWidth: '96vw' }
|
||
: width != null
|
||
? { width, maxWidth: '96vw' }
|
||
: { width: 'fit-content', maxWidth: '96vw' }
|
||
}
|
||
>
|
||
{title != null && (
|
||
<div
|
||
className={cn(
|
||
'flex shrink-0 touch-none select-none items-center gap-2 border-b border-border/60 bg-white/[0.03] px-2.5 py-1.5',
|
||
isFloating && 'cursor-grab active:cursor-grabbing',
|
||
)}
|
||
{...titleBarHandlers}
|
||
>
|
||
{titleIcon && (
|
||
<span className="shrink-0 text-muted-foreground" aria-hidden>
|
||
{titleIcon}
|
||
</span>
|
||
)}
|
||
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-sm font-semibold tracking-wide">
|
||
{title}
|
||
</span>
|
||
{titleExtra}
|
||
{onDockChange && (
|
||
<span className="flex shrink-0 items-center gap-0.5" data-no-drag>
|
||
<button
|
||
type="button"
|
||
data-testid="dock-left"
|
||
aria-label="Dock to the left edge"
|
||
title="Dock to the left edge"
|
||
className={cn(
|
||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||
dock === 'left' && 'bg-white/10 text-foreground',
|
||
)}
|
||
onClick={() => onDockChange('left')}
|
||
>
|
||
<AlignLeft className="size-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
data-testid="dock-right"
|
||
aria-label="Dock to the right edge"
|
||
title="Dock to the right edge"
|
||
className={cn(
|
||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||
dock === 'right' && 'bg-white/10 text-foreground',
|
||
)}
|
||
onClick={() => onDockChange('right')}
|
||
>
|
||
<AlignRight className="size-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
data-testid="dock-float"
|
||
aria-label="Float the window"
|
||
title="Float (undock)"
|
||
className={cn(
|
||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||
!isDocked && 'bg-white/10 text-foreground',
|
||
)}
|
||
onClick={() => onDockChange('float')}
|
||
>
|
||
<Maximize2 className="size-3.5" />
|
||
</button>
|
||
</span>
|
||
)}
|
||
{collapsibleProp && (
|
||
<button
|
||
type="button"
|
||
aria-label={collapsed ? 'Expand window' : 'Collapse window'}
|
||
title={collapsed ? 'Expand' : 'Collapse'}
|
||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
||
onClick={() => onCollapsedChange?.(!collapsed)}
|
||
>
|
||
{collapsed ? <Plus className="size-3.5" /> : <Minus className="size-3.5" />}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{children != null && <div className={bodyClass}>{children}</div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|