"use client"; import { ReactNode, useCallback, useRef, useLayoutEffect } from "react"; import { Node, NodeResizer, OnResize, useReactFlow } from "@xyflow/react"; import { useWorkflowStore } from "@/store/workflowStore"; import { isPanningRef } from "@/components/WorkflowCanvas"; import { getMediaDimensions, calculateAspectFitSize } from "@/utils/nodeDimensions"; const DEFAULT_NODE_DIMENSION = 300; interface BaseNodeProps { id: string; children: ReactNode; selected?: boolean; isExecuting?: boolean; hasError?: boolean; className?: string; contentClassName?: string; minWidth?: number; minHeight?: number; /** When true, node has no background/border — content fills the entire node area */ fullBleed?: boolean; /** Media URL (image/video) to use for aspect-fit resize on resize-handle double-click */ aspectFitMedia?: string | null; /** When true, bottom corners lose rounding so the selection ring connects to the settings panel below */ settingsExpanded?: boolean; /** Settings panel rendered outside the bordered area so it shares the node's full width */ settingsPanel?: ReactNode; } /** * Read a node's effective width or height, respecting React Flow's internal * priority: node.width > node.style.width > node.measured.width. */ function getNodeDimension(node: Node, axis: "width" | "height"): number { return ( (node[axis] as number) ?? (node.style?.[axis] as number) ?? (node.measured?.[axis] as number) ?? DEFAULT_NODE_DIMENSION ); } /** * Apply dimensions to a React Flow node, writing to both `node.width/height` * (where NodeResizer writes) and `node.style` (the original source) so neither * silently overrides the other. */ function applyNodeDimensions(node: Node, width: number, height: number): Node { return { ...node, width, height, style: { ...node.style, width, height }, }; } export function BaseNode({ id, children, selected = false, isExecuting = false, hasError = false, className = "", contentClassName, minWidth = 180, minHeight = 100, fullBleed = false, aspectFitMedia, settingsExpanded = false, settingsPanel, }: BaseNodeProps) { const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds); const setHoveredNodeId = useWorkflowStore((state) => state.setHoveredNodeId); const isCurrentlyExecuting = currentNodeIds.includes(id); const { getNodes, setNodes } = useReactFlow(); const settingsPanelRef = useRef(null); const contentRef = useRef(null); const trackedSettingsHeightRef = useRef(0); const isAnimatingRef = useRef(false); const animationTimeoutRef = useRef | null>(null); const isInitialMountRef = useRef(true); // When true, the ResizeObserver should sync trackedSettingsHeightRef without // modifying node dimensions (the height is already accounted for from a // previous mount before onlyRenderVisibleElements unmounted us). const skipFirstObserveRef = useRef(false); // Adjust node height when settings expand or collapse useLayoutEffect(() => { // On initial mount with settings already expanded (e.g. remount after // onlyRenderVisibleElements), the node height already includes the panel. // Tell the ResizeObserver to sync without adding height. if (isInitialMountRef.current) { isInitialMountRef.current = false; if (settingsExpanded && settingsPanel) { skipFirstObserveRef.current = true; } return; } // Cancel any pending animation timeout from a previous toggle (handles rapid toggling) if (animationTimeoutRef.current) { clearTimeout(animationTimeoutRef.current); animationTimeoutRef.current = null; } const contentEl = contentRef.current; const ANIMATION_MS = 160; if (!settingsExpanded && trackedSettingsHeightRef.current > 0) { // --- COLLAPSE --- const heightToRemove = trackedSettingsHeightRef.current; trackedSettingsHeightRef.current = 0; isAnimatingRef.current = true; // Lock content height for the full animation duration if (contentEl) { contentEl.style.height = contentEl.offsetHeight + "px"; } setNodes((nodes) => nodes.map((node) => { if (node.id !== id) return node; const currentHeight = getNodeDimension(node, "height"); const newHeight = Math.max(minHeight, currentHeight - heightToRemove); return applyNodeDimensions(node, getNodeDimension(node, "width"), newHeight); }) ); animationTimeoutRef.current = setTimeout(() => { isAnimatingRef.current = false; if (contentEl) contentEl.style.height = ""; }, ANIMATION_MS); } else if (settingsExpanded && settingsPanel) { // --- EXPAND --- // Lock the content wrapper rigid so flex can't redistribute space as the // settings panel grows. Without this, flex-1 + min-h-0 lets the wrapper // shrink between CSS transition frames and the ResizeObserver setNodes catch-up. isAnimatingRef.current = true; if (contentEl) { const wrapperEl = contentEl.parentElement as HTMLElement | null; if (wrapperEl) { wrapperEl.style.flex = "none"; wrapperEl.style.height = wrapperEl.offsetHeight + "px"; } } animationTimeoutRef.current = setTimeout(() => { isAnimatingRef.current = false; // Apply the final panel height in one shot, then unlock the wrapper const finalHeight = trackedSettingsHeightRef.current; if (finalHeight > 0) { setNodes((nodes) => nodes.map((node) => { if (node.id !== id) return node; const currentHeight = getNodeDimension(node, "height"); return applyNodeDimensions(node, getNodeDimension(node, "width"), currentHeight + finalHeight); }) ); } if (contentEl) { const wrapperEl = contentEl.parentElement as HTMLElement | null; if (wrapperEl) { wrapperEl.style.flex = ""; wrapperEl.style.height = ""; } } }, ANIMATION_MS); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [settingsExpanded]); // ResizeObserver to track dynamic settings panel height changes (e.g., model param count changes) useLayoutEffect(() => { if (!settingsExpanded || !settingsPanel) return; const panelEl = settingsPanelRef.current; if (!panelEl) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { const newPanelHeight = entry.contentRect.height; if (newPanelHeight === 0) continue; // On remount with settings already expanded, the node height already // includes the panel. Just sync the ref without modifying dimensions. if (skipFirstObserveRef.current) { skipFirstObserveRef.current = false; trackedSettingsHeightRef.current = newPanelHeight; continue; } const delta = newPanelHeight - trackedSettingsHeightRef.current; if (Math.abs(delta) < 2) continue; // Ignore sub-pixel changes trackedSettingsHeightRef.current = newPanelHeight; // During animation, just track the height — skip setNodes to avoid // multiple re-renders. The expand timeout will apply one final update. if (isAnimatingRef.current) continue; // Lock content height to prevent image flicker during resize const contentEl = contentRef.current; if (contentEl) { contentEl.style.height = contentEl.offsetHeight + "px"; } setNodes((nodes) => nodes.map((node) => { if (node.id !== id) return node; const currentHeight = getNodeDimension(node, "height"); const newHeight = Math.max(minHeight, currentHeight + delta); return applyNodeDimensions(node, getNodeDimension(node, "width"), newHeight); }) ); // Release locked height after layout settles requestAnimationFrame(() => { if (contentEl) { contentEl.style.height = ""; } }); } }); observer.observe(panelEl); return () => observer.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [settingsExpanded, settingsPanel]); // Cleanup animation timeout on unmount useLayoutEffect(() => { return () => { if (animationTimeoutRef.current) { clearTimeout(animationTimeoutRef.current); } }; }, []); const handleResize: OnResize = useCallback( (_event, params) => { setNodes((nodes) => nodes.map((node) => { if (node.selected && node.id !== id) { return applyNodeDimensions(node, params.width, params.height); } return node; }) ); }, [id, setNodes] ); const handleResizeHandleDblClick = useCallback( async (e: React.MouseEvent) => { const target = e.target as HTMLElement; if (!target.closest(".react-flow__resize-control")) return; if (!aspectFitMedia) return; e.stopPropagation(); const dims = await getMediaDimensions(aspectFitMedia); if (!dims) return; const thisNode = getNodes().find((n) => n.id === id); if (!thisNode) return; const nodeHeight = getNodeDimension(thisNode, "height"); const contentHeight = nodeHeight - trackedSettingsHeightRef.current; const newSize = calculateAspectFitSize( dims.width / dims.height, getNodeDimension(thisNode, "width"), contentHeight, fullBleed ); const finalHeight = newSize.height + trackedSettingsHeightRef.current; setNodes((nds) => nds.map((n) => { if (n.id === id || (n.selected && n.id !== id)) { return applyNodeDimensions(n, newSize.width, finalHeight); } return n; }) ); }, [aspectFitMedia, id, fullBleed, getNodes, setNodes] ); const hasExpandedSettings = settingsExpanded && settingsPanel; return (
{ if (isPanningRef.current) return; setHoveredNodeId(id); }} onMouseLeave={() => { if (isPanningRef.current) return; setHoveredNodeId(null); }} >
{children}
{settingsPanel && (
{settingsPanel}
)}
); }