You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
339 lines
14 KiB
339 lines
14 KiB
"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<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const trackedSettingsHeightRef = useRef(0);
|
|
const isAnimatingRef = useRef(false);
|
|
const animationTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<string, unknown>)?._settingsPanelHeight === "number"
|
|
? (node.data as Record<string, unknown>)._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 ? (
|
|
<button
|
|
onClick={() => browseRegistry.open(id)}
|
|
className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
|
|
>
|
|
{t("common.browse")}
|
|
</button>
|
|
) : undefined;
|
|
const showHeader = Boolean(node && node.type !== "group" && !isPreviewMode);
|
|
const header = showHeader ? (
|
|
<FloatingNodeHeader
|
|
id={id}
|
|
type={node!.type as NodeType}
|
|
isInLockedGroup={Boolean((node!.data as Record<string, unknown>)?.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 (
|
|
<div
|
|
className={hasExpandedSettings
|
|
? `relative flex flex-col w-full h-full overflow-visible rounded-lg bg-[var(--surface-2)] text-[var(--text-primary)] shadow-[var(--shadow-node)] ${selected ? "ring-2 ring-[var(--node-selected-ring)] shadow-[var(--node-selected-shadow)]" : ""}`
|
|
: "contents"}
|
|
data-tutorial={hasExpandedSettings ? dataTutorial : undefined}
|
|
>
|
|
<div
|
|
className={`
|
|
${autoSizeContent ? "h-full w-full" : hasExpandedSettings ? "flex-1 min-h-0 w-full" : "h-full w-full"} flex flex-col overflow-visible relative
|
|
${fullBleed
|
|
? `${settingsExpanded ? "rounded-t-lg border-b-0" : "rounded-lg"} border border-[var(--border-subtle)] bg-[var(--surface-2)] text-[var(--text-primary)] shadow-[var(--shadow-node)]`
|
|
: `bg-[var(--surface-2)] text-[var(--text-primary)] ${settingsExpanded ? "rounded-t-lg border-b-0" : "rounded-lg"} shadow-[var(--shadow-node)] border`}
|
|
${fullBleed ? "" : (isCurrentlyExecuting || isExecuting ? "border-blue-500 ring-1 ring-blue-500/20" : "border-[var(--border-subtle)]")}
|
|
${fullBleed ? "" : (hasError ? "border-red-500" : "")}
|
|
${fullBleed && selected && !settingsExpanded ? "ring-2 ring-[var(--node-selected-ring)] shadow-[var(--node-selected-shadow)]" : ""}
|
|
${!fullBleed && selected && !settingsExpanded ? "border-[var(--node-selected-border)] ring-2 ring-[var(--node-selected-ring)] shadow-[var(--node-selected-shadow)]" : ""}
|
|
${!fullBleed && selected && settingsExpanded ? "border-[var(--node-selected-border)]" : ""}
|
|
${className}
|
|
`}
|
|
data-tutorial={!hasExpandedSettings ? dataTutorial : undefined}
|
|
onMouseEnter={(e) => {
|
|
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}
|
|
<div ref={contentRef} style={{ contain: "layout style" }} className={contentClassName ?? (autoSizeContent ? "relative h-full w-full" : fullBleed ? "flex-1 min-h-0 relative" : "px-3 pb-4 flex-1 min-h-0 overflow-visible flex flex-col")}>{children}</div>
|
|
{floatingPanel}
|
|
{!isPreviewMode && <NodeGenerationComposer nodeId={id} selected={selected} />}
|
|
</div>
|
|
{settingsPanel && (
|
|
<div ref={settingsPanelRef}>
|
|
{settingsPanel}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|