155 lines
7.2 KiB
TypeScript
155 lines
7.2 KiB
TypeScript
import { useCallback, useEffect, useRef, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
export const DITHERED_SHADOW = `
|
|
3px 3px 0 0 var(--border), 5px 3px 0 0 transparent, 7px 3px 0 0 var(--border),
|
|
4px 4px 0 0 transparent, 6px 4px 0 0 var(--border),
|
|
3px 5px 0 0 var(--border), 5px 5px 0 0 transparent, 7px 5px 0 0 var(--border),
|
|
4px 6px 0 0 var(--border), 6px 6px 0 0 transparent
|
|
`;
|
|
|
|
export interface FloatingWindowProps {
|
|
id: string;
|
|
title: string;
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
zIndex: number;
|
|
focused: boolean;
|
|
onUpdate: (id: string, patch: { x?: number; y?: number; w?: number; h?: number }) => void;
|
|
onClose: (id: string) => void;
|
|
onFocus: (id: string) => void;
|
|
minW?: number;
|
|
minH?: number;
|
|
addressBar?: ReactNode;
|
|
footer?: ReactNode;
|
|
containerRef?: React.RefObject<HTMLDivElement | null>;
|
|
children: ReactNode;
|
|
}
|
|
|
|
export default function FloatingWindow({
|
|
id, title, x, y, w, h, zIndex, focused,
|
|
onUpdate, onClose, onFocus,
|
|
minW = 320, minH = 200,
|
|
addressBar, footer, containerRef, children,
|
|
}: FloatingWindowProps) {
|
|
const internalRef = useRef<HTMLDivElement>(null);
|
|
const ref = containerRef ?? internalRef;
|
|
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
|
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
|
|
const velocityRef = useRef({ vx: 0, vy: 0, lastX: 0, lastY: 0, lastT: 0 });
|
|
const inertiaRef = useRef(0);
|
|
const posRef = useRef({ x, y });
|
|
posRef.current = { x, y };
|
|
|
|
useEffect(() => { if (focused) ref.current?.focus(); }, [focused]);
|
|
|
|
// Cancel any running inertia animation
|
|
const stopInertia = useCallback(() => {
|
|
if (inertiaRef.current) { cancelAnimationFrame(inertiaRef.current); inertiaRef.current = 0; }
|
|
}, []);
|
|
|
|
const onDragStart = useCallback((e: React.MouseEvent) => {
|
|
if ((e.target as HTMLElement).closest("button")) return;
|
|
e.preventDefault(); onFocus(id);
|
|
ref.current?.focus();
|
|
stopInertia();
|
|
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: x, origY: y };
|
|
velocityRef.current = { vx: 0, vy: 0, lastX: e.clientX, lastY: e.clientY, lastT: performance.now() };
|
|
document.documentElement.classList.add("cursor-grabbing");
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
if (!dragRef.current) return;
|
|
const now = performance.now();
|
|
const dt = now - velocityRef.current.lastT;
|
|
if (dt > 0) {
|
|
const smooth = 0.3;
|
|
const rawVx = (ev.clientX - velocityRef.current.lastX) / dt * 16;
|
|
const rawVy = (ev.clientY - velocityRef.current.lastY) / dt * 16;
|
|
velocityRef.current.vx = velocityRef.current.vx * (1 - smooth) + rawVx * smooth;
|
|
velocityRef.current.vy = velocityRef.current.vy * (1 - smooth) + rawVy * smooth;
|
|
velocityRef.current.lastX = ev.clientX;
|
|
velocityRef.current.lastY = ev.clientY;
|
|
velocityRef.current.lastT = now;
|
|
}
|
|
onUpdate(id, {
|
|
x: dragRef.current.origX + (ev.clientX - dragRef.current.startX),
|
|
y: Math.max(0, dragRef.current.origY + (ev.clientY - dragRef.current.startY)),
|
|
});
|
|
};
|
|
|
|
const onUp = () => {
|
|
const { vx, vy } = velocityRef.current;
|
|
dragRef.current = null;
|
|
document.documentElement.classList.remove("cursor-grabbing");
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
|
|
// Kick off inertia if there's meaningful velocity
|
|
if (Math.abs(vx) > 0.5 || Math.abs(vy) > 0.5) {
|
|
let curVx = vx;
|
|
let curVy = vy;
|
|
const friction = 0.92;
|
|
const el = ref.current;
|
|
const tick = () => {
|
|
curVx *= friction;
|
|
curVy *= friction;
|
|
if (Math.abs(curVx) < 0.3 && Math.abs(curVy) < 0.3) {
|
|
inertiaRef.current = 0;
|
|
// Sync final position to React state once
|
|
onUpdate(id, posRef.current);
|
|
return;
|
|
}
|
|
posRef.current = { x: posRef.current.x + curVx, y: Math.max(0, posRef.current.y + curVy) };
|
|
// Direct DOM update during animation — skip React reconciliation
|
|
if (el) {
|
|
el.style.left = `${posRef.current.x}px`;
|
|
el.style.top = `${posRef.current.y}px`;
|
|
}
|
|
inertiaRef.current = requestAnimationFrame(tick);
|
|
};
|
|
inertiaRef.current = requestAnimationFrame(tick);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
}, [id, x, y, onUpdate, onFocus, stopInertia]);
|
|
|
|
const onResizeStart = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault(); e.stopPropagation(); onFocus(id);
|
|
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: w, origH: h };
|
|
document.documentElement.classList.add("cursor-nwse-resize");
|
|
const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return; onUpdate(id, { w: Math.max(minW, resizeRef.current.origW + (ev.clientX - resizeRef.current.startX)), h: Math.max(minH, resizeRef.current.origH + (ev.clientY - resizeRef.current.startY)) }); };
|
|
const onUp = () => { resizeRef.current = null; document.documentElement.classList.remove("cursor-nwse-resize"); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
|
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
|
|
}, [id, w, h, minW, minH, onUpdate, onFocus]);
|
|
|
|
return createPortal(
|
|
<div ref={ref} tabIndex={-1} onKeyDown={(e) => { if (e.key === "Escape") onClose(id); }} onMouseDown={() => onFocus(id)}
|
|
className="fixed z-999 flex flex-col bg-popover text-popover-foreground border-2 rounded-lg outline-none transition-[border-color,opacity] duration-150"
|
|
style={{ left: x, top: y, width: w, height: h, zIndex: 999 + zIndex, borderColor: focused ? "var(--primary)" : "var(--border)", opacity: focused ? 1 : 0.85, boxShadow: DITHERED_SHADOW }}>
|
|
{/* Title bar */}
|
|
<div onMouseDown={onDragStart} className="flex items-center gap-2 px-3 py-1.5 border-b-2 border-border cursor-grab active:cursor-grabbing select-none shrink-0 bg-muted/30 rounded-t-lg">
|
|
<div className="flex items-center gap-1.5">
|
|
<button onClick={() => onClose(id)} className="w-2.5 h-2.5 rounded-full bg-destructive hover:brightness-125 transition-all" />
|
|
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" /><span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
|
|
</div>
|
|
<span className="flex-1 text-[10px] font-semibold uppercase tracking-wider truncate text-center">{title}</span>
|
|
</div>
|
|
{/* Optional address bar */}
|
|
{addressBar}
|
|
{/* Content */}
|
|
<div className="flex-1 min-h-0">
|
|
{children}
|
|
</div>
|
|
{/* Optional footer */}
|
|
{footer}
|
|
{/* Resize handle */}
|
|
<div onMouseDown={onResizeStart} className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize" style={{ touchAction: "none" }}>
|
|
<svg viewBox="0 0 16 16" className="w-full h-full text-muted-foreground/50"><path d="M14 14L8 14L14 8Z" fill="currentColor" /><path d="M14 14L11 14L14 11Z" fill="currentColor" opacity="0.5" /></svg>
|
|
</div>
|
|
</div>, document.body);
|
|
}
|