"use client"; import { ReactNode, useRef, useLayoutEffect } from "react"; import { Node, useReactFlow } from "@xyflow/react"; import { useWorkflowStore } from "@/store/workflowStore"; import { isPanningRef, isDraggingNodeRef } from "@/components/WorkflowCanvas"; import { NodeGenerationComposer } from "@/components/composer/NodeGenerationComposer"; import { useCanvasPreviewMode } from "@/components/CanvasPreviewModeContext"; import { useI18n } from "@/i18n"; import { isPopiProviderMode } from "@/lib/providerMode"; import type { NodeType, WorkflowNode, WorkflowNodeData } from "@/types"; import { useInlineParameters } from "@/hooks/useInlineParameters"; import { browseRegistry } from "@/utils/browseRegistry"; import { isSmartAudioGenerateMode } from "@/utils/smartAudioMode"; import { isSmartImageGenerateMode } from "@/utils/smartImageMode"; import { isSmartVideoGenerateMode } from "@/utils/smartVideoMode"; import { FloatingNodeHeader } from "./FloatingNodeHeader"; import { useNodeHeaderContext } from "./NodeHeaderContext"; import { getNodeHeaderMediaMeta } from "./nodeHeaderMedia"; import { getEffectiveNodeHeaderModel, getNodeHeaderTitle } from "./nodeHeaderTitle"; const DEFAULT_NODE_DIMENSION = 300; interface BaseNodeProps { id: string; children: ReactNode; selected?: boolean; isExecuting?: boolean; hasError?: boolean; className?: string; contentClassName?: string; autoSizeContent?: boolean; minWidth?: number; minHeight?: number; /** When true, node has no background/border — content fills the entire node area */ fullBleed?: boolean; /** 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; /** Floating UI rendered outside clipped node content, for selected-node panels. */ floatingPanel?: ReactNode; /** Tutorial identifier for highlighting */ dataTutorial?: string; } /** * Read a node's effective width or height from persisted style first, falling * back locally without mutating imported workflow data. */ function getNodeDimension(node: Node, axis: "width" | "height"): number { return ( (node.style?.[axis] as number) ?? (node.measured?.[axis] as number) ?? DEFAULT_NODE_DIMENSION ); } /** * Apply dimensions to persisted node style. Business sizing is stored in * `style.width/height`; React Flow can keep its own measured/top-level fields. */ function applyNodeDimensions(node: Node, width: number, height: number): Node { return { ...node, style: { ...node.style, width, height }, }; } export function BaseNode({ id, children, selected = false, isExecuting = false, hasError = false, className = "", contentClassName, autoSizeContent = false, minWidth = 180, minHeight = 100, fullBleed = false, settingsExpanded = false, settingsPanel, floatingPanel, dataTutorial, }: BaseNodeProps) { const { t } = useI18n(); const isPreviewMode = useCanvasPreviewMode(); const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds); const setHoveredNodeId = useWorkflowStore((state) => state.setHoveredNodeId); const node = useWorkflowStore((state) => state.nodes.find((candidate) => candidate.id === id) as WorkflowNode | undefined); const edges = useWorkflowStore((state) => state.edges); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const { onExpandNode } = useNodeHeaderContext(); const { inlineParametersEnabled } = useInlineParameters(); const isCurrentlyExecuting = currentNodeIds.includes(id); const { setNodes } = useReactFlow(); const settingsPanelRef = useRef(null); const contentRef = useRef(null); const trackedSettingsHeightRef = useRef(0); const isAnimatingRef = useRef(false); const animationTimeoutRef = useRef | null>(null); // Adjust node height when settings expand or collapse useLayoutEffect(() => { // 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), data: { ...node.data, _settingsPanelHeight: 0 }, }; }) ); 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. // Subtract any previously saved panel height to avoid double-counting // on workflow reload (saved node height already includes panel). const finalHeight = trackedSettingsHeightRef.current; if (finalHeight > 0) { setNodes((nodes) => nodes.map((node) => { if (node.id !== id) return node; const savedPanelHeight = typeof (node.data as Record)?._settingsPanelHeight === "number" ? (node.data as Record)._settingsPanelHeight as number : 0; const heightToAdd = finalHeight - savedPanelHeight; const currentHeight = getNodeDimension(node, "height"); const newHeight = Math.max(minHeight, currentHeight + heightToAdd); return { ...applyNodeDimensions(node, getNodeDimension(node, "width"), newHeight), data: { ...node.data, _settingsPanelHeight: 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; 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), data: { ...node.data, _settingsPanelHeight: newPanelHeight }, }; }) ); // 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 hasExpandedSettings = settingsExpanded && settingsPanel; const nodeTitle = node ? getNodeHeaderTitle(node, edges, t) : ""; const mediaMeta = node ? getNodeHeaderMediaMeta(node, nodeTitle, edges) : null; const customTitle = typeof node?.data?.customTitle === "string" ? node.data.customTitle : undefined; const effectiveModel = node ? getEffectiveNodeHeaderModel(node, edges) : undefined; const nodeData = node?.data as WorkflowNodeData | undefined; const showBrowse = Boolean(node && inlineParametersEnabled && ( (node.type === "smartImage" && isSmartImageGenerateMode(node.id, nodeData, edges)) || (node.type === "smartAudio" && isSmartAudioGenerateMode(node.id, nodeData, edges)) || (node.type === "smartVideo" && isSmartVideoGenerateMode(node.id, nodeData, edges)) || node.type === "nanoBanana" || node.type === "generateVideo" || node.type === "generate3d" || node.type === "generateAudio" )); const browseAction = showBrowse ? ( ) : undefined; const showHeader = Boolean(node && node.type !== "group" && !isPreviewMode); const header = showHeader ? ( )?.isInLockedGroup)} selected={selected} title={nodeTitle} mediaMeta={mediaMeta} customTitle={customTitle} provider={isPopiProviderMode() ? undefined : effectiveModel?.provider} headerAction={browseAction} onCustomTitleChange={(nodeId, title) => updateNodeData(nodeId, { customTitle: title || undefined })} onExpandNode={onExpandNode} /> ) : null; return (
{ if (isPreviewMode) return; if (e.buttons !== 0 || isPanningRef.current || isDraggingNodeRef.current) return; setHoveredNodeId(id); }} onMouseLeave={(e) => { if (isPreviewMode) return; if (e.buttons !== 0 || isPanningRef.current || isDraggingNodeRef.current) return; setHoveredNodeId(null); }} > {header}
{children}
{floatingPanel} {!isPreviewMode && }
{settingsPanel && (
{settingsPanel}
)}
); }