181 lines
6.5 KiB
TypeScript
181 lines
6.5 KiB
TypeScript
import type { CSSProperties, ComponentPropsWithoutRef, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
|
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import { ChevronDown, ChevronUp, Minus, Plus, X } from 'lucide-react';
|
|
import { cn } from 'helpers/cn';
|
|
import { Card } from '@/components/Card';
|
|
|
|
type Position = { x: number; y: number };
|
|
|
|
type WidgetProps = ComponentPropsWithoutRef<'div'> & {
|
|
title?: string;
|
|
resizable?: boolean;
|
|
collapsible?: boolean | { title: string; icon?: LucideIcon };
|
|
minimizable?: boolean;
|
|
moveable?: boolean;
|
|
position?: Position;
|
|
onPositionChange?: (pos: Position) => void;
|
|
onClose?: () => void;
|
|
};
|
|
|
|
export const Widget = ({ title, className, style, resizable, collapsible, minimizable = false, moveable, position: controlledPosition, onPositionChange, onClose, children, ...props }: WidgetProps) => {
|
|
const [expanded, setExpanded] = useState(true);
|
|
const [minimized, setMinimized] = useState(false);
|
|
const [internalPosition, setInternalPosition] = useState<Position>({ x: 0, y: 0 });
|
|
const isControlled = controlledPosition !== undefined;
|
|
const position = isControlled ? controlledPosition : internalPosition;
|
|
const setPosition = isControlled ? (pos: Position | ((prev: Position) => Position)) => {
|
|
const next = typeof pos === 'function' ? pos(controlledPosition) : pos;
|
|
onPositionChange?.(next);
|
|
} : setInternalPosition;
|
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
const dragRef = useRef<{
|
|
startX: number;
|
|
startY: number;
|
|
originX: number;
|
|
originY: number;
|
|
naturalLeft: number;
|
|
naturalTop: number;
|
|
cardWidth: number;
|
|
cardHeight: number;
|
|
} | null>(null);
|
|
const preToggleRect = useRef<{ left: number; top: number } | null>(null);
|
|
|
|
const HEADER_HEIGHT = 64;
|
|
|
|
const toggleMinimized = useCallback(() => {
|
|
if (cardRef.current) {
|
|
const rect = cardRef.current.getBoundingClientRect();
|
|
preToggleRect.current = { left: rect.left, top: rect.top };
|
|
}
|
|
setMinimized((v) => !v);
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!cardRef.current || !preToggleRect.current) return;
|
|
const prev = preToggleRect.current;
|
|
preToggleRect.current = null;
|
|
const newRect = cardRef.current.getBoundingClientRect();
|
|
setPosition((p) => ({
|
|
x: p.x + (prev.left - newRect.left),
|
|
y: p.y + (prev.top - newRect.top),
|
|
}));
|
|
}, [minimized]);
|
|
|
|
const onPointerDown = useCallback(
|
|
(ev: ReactPointerEvent) => {
|
|
if (!moveable) return;
|
|
const target = ev.target as HTMLElement;
|
|
const card = ev.currentTarget as HTMLElement;
|
|
const isCardPadding = target === card;
|
|
const isHeader = !isCardPadding && target.closest('[data-widget-header]') && !target.closest('button');
|
|
if (!isCardPadding && !isHeader) return;
|
|
if (minimizable && isHeader && ev.detail === 2) {
|
|
toggleMinimized();
|
|
return;
|
|
}
|
|
const rect = card.getBoundingClientRect();
|
|
dragRef.current = {
|
|
startX: ev.clientX,
|
|
startY: ev.clientY,
|
|
originX: position.x,
|
|
originY: position.y,
|
|
naturalLeft: rect.left - position.x,
|
|
naturalTop: rect.top - position.y,
|
|
cardWidth: rect.width,
|
|
cardHeight: rect.height,
|
|
};
|
|
card.setPointerCapture(ev.pointerId);
|
|
},
|
|
[moveable, position],
|
|
);
|
|
|
|
const onPointerMove = useCallback((ev: ReactPointerEvent) => {
|
|
if (!dragRef.current) return;
|
|
const d = dragRef.current;
|
|
const dx = ev.clientX - d.startX;
|
|
const dy = ev.clientY - d.startY;
|
|
const newX = d.originX + dx;
|
|
const newY = d.originY + dy;
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
setPosition({
|
|
x: Math.min(Math.max(newX, -d.naturalLeft), vw - d.naturalLeft - d.cardWidth),
|
|
y: Math.min(Math.max(newY, HEADER_HEIGHT - d.naturalTop), vh - d.naturalTop - d.cardHeight),
|
|
});
|
|
}, []);
|
|
|
|
const onPointerUp = useCallback(() => {
|
|
dragRef.current = null;
|
|
}, []);
|
|
|
|
const resizableStyle: CSSProperties | undefined = resizable ? { resize: 'both', overflow: 'auto' } : undefined;
|
|
const moveableStyle: CSSProperties | undefined = moveable
|
|
? { position: 'relative', transform: `translate(${position.x}px, ${position.y}px)` }
|
|
: undefined;
|
|
|
|
return (
|
|
<Card
|
|
ref={cardRef}
|
|
className={cn(
|
|
'relative p-0',
|
|
collapsible && 'pt-0',
|
|
moveable && 'cursor-grab [&>*]:cursor-auto',
|
|
className,
|
|
minimized && '!h-auto !w-auto',
|
|
)}
|
|
style={{ ...resizableStyle, ...moveableStyle, ...style }}
|
|
onPointerDown={moveable ? onPointerDown : undefined}
|
|
onPointerMove={moveable ? onPointerMove : undefined}
|
|
onPointerUp={moveable ? onPointerUp : undefined}
|
|
{...props}
|
|
>
|
|
<div data-widget-header className="flex h-8 items-center gap-12 px-3 select-none cursor-grab">
|
|
{title && <span className="text-sm font-bold text-muted-foreground pointer-events-none">{title}</span>}
|
|
<div className="ml-auto flex items-center gap-0.5">
|
|
{minimizable && (
|
|
<button
|
|
type="button"
|
|
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
|
onClick={() => toggleMinimized()}
|
|
>
|
|
{minimized ? <Plus size={14} /> : <Minus size={14} />}
|
|
</button>
|
|
)}
|
|
{onClose && (
|
|
<button
|
|
type="button"
|
|
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
|
onClick={onClose}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!minimized && (
|
|
<>
|
|
{collapsible && (
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2 p-3 cursor-pointer"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
>
|
|
{typeof collapsible === 'object' && collapsible.icon && (
|
|
<collapsible.icon size={16} className="text-muted-foreground" />
|
|
)}
|
|
{typeof collapsible === 'object' && collapsible.title && (
|
|
<span className="text-sm text-muted-foreground">{collapsible.title}</span>
|
|
)}
|
|
<span className="ml-auto text-muted-foreground">
|
|
{expanded ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
|
</span>
|
|
</button>
|
|
)}
|
|
{collapsible ? expanded && <div className="px-5 pb-5">{children}</div> : children}
|
|
</>
|
|
)}
|
|
</Card>
|
|
);
|
|
};
|