Files
History/src/components/FloatingWindow.tsx
T
2026-10-11 17:40:01 +00:00

447 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}