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({ 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(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 ( *]: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} >
{title && {title}}
{minimizable && ( )} {onClose && ( )}
{!minimized && ( <> {collapsible && ( )} {collapsible ? expanded &&
{children}
: children} )}
); };