import { cn } from "@/lib/utils"; import type { LivestreamStore } from "@streamplace/core"; import { ChevronDown, GripVertical, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { DragState } from "./layout-renderer"; import { WIDGET_KEYS, getWidgetMeta } from "./registry"; interface WidgetLeafProps { widgetKey: string; store: LivestreamStore; user?: string; /** Called when the user picks a different widget from the dropdown. */ onWidgetChange?: (newWidget: string) => void; /** Called when the user clicks the X to remove this slot from the tree. */ onWidgetRemove?: () => void; /** Whether this leaf is currently being dragged. */ isDragging?: boolean; /** Ref for the drag handle (dnd-kit handleRef). */ handleRef?: React.Ref; /** Ref for the sortable element (dnd-kit ref). */ sortableRef?: React.Ref; /** * When non-null, this leaf is the current drop target. Pointer position * is used to draw a split-preview overlay (which half the source widget * would land in on drop). */ dropTarget?: DragState | null; /** Unique sortable id, used to attach a data attribute for lookup. */ leafId?: string; } /** * Leaf chrome: title bar with widget icon, drag handle, and a dropdown * to swap the widget type. Wraps the actual widget component. */ export function WidgetLeaf({ widgetKey, store, user, onWidgetChange, onWidgetRemove, isDragging, handleRef, sortableRef, dropTarget, leafId, }: WidgetLeafProps) { const meta = getWidgetMeta(widgetKey); const [dropdownOpen, setDropdownOpen] = useState(false); const rootRef = useRef(null); const [quadrant, setQuadrant] = useState(null); // Recompute which quadrant of the leaf the cursor is over whenever // the drop target updates. This drives the split-preview overlay. useEffect(() => { if (!dropTarget || !rootRef.current) { setQuadrant(null); return; } const rect = rootRef.current.getBoundingClientRect(); const relX = (dropTarget.pointer.x - rect.left) / rect.width; const relY = (dropTarget.pointer.y - rect.top) / rect.height; if (relX < 0 || relX > 1 || relY < 0 || relY > 1) { setQuadrant(null); return; } const horizontalDominant = Math.abs(relX - 0.5) >= Math.abs(relY - 0.5); setQuadrant({ direction: horizontalDominant ? "horizontal" : "vertical", first: horizontalDominant ? relX < 0.5 : relY < 0.5, }); }, [dropTarget]); const setRefs = useCallback( (el: HTMLDivElement | null) => { rootRef.current = el; if (typeof sortableRef === "function") sortableRef(el); else if (sortableRef && "current" in sortableRef) { (sortableRef as React.MutableRefObject).current = el; } }, [sortableRef], ); if (!meta) { return (
Unknown widget: {widgetKey}
); } const Icon = meta.icon; const WidgetComponent = meta.component; return (
{/* Title bar */}
{/* Drag handle */}
{/* Widget title / swap dropdown */}
{dropdownOpen && onWidgetChange && ( <> {/* Backdrop to close dropdown */}
setDropdownOpen(false)} />
{WIDGET_KEYS.filter((k) => k !== "blank").map((key) => { const wMeta = getWidgetMeta(key); if (!wMeta) return null; const WIcon = wMeta.icon; return ( ); })}
)}
{/* Close: remove this slot from the layout tree. Only available when a remove handler is passed down. */} {onWidgetRemove && ( )}
{/* Widget content */}
{/* Split-preview overlay: only shown when this leaf is the drop target. Renders two translucent halves plus divider lines at the leaf's center, highlighting the half the cursor is over. */} {dropTarget && quadrant && }
); } interface Quadrant { direction: "horizontal" | "vertical"; /** True if cursor is in the left/top half. */ first: boolean; } function SplitPreview({ quadrant }: { quadrant: Quadrant }) { const horizontal = quadrant.direction === "horizontal"; return (
{/* Highlighted half */}
{/* Divider line at the center */}
); }