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.
237 lines
8.4 KiB
237 lines
8.4 KiB
"use client";
|
|
|
|
import { ReactNode, useCallback, useRef, useLayoutEffect, useState } from "react";
|
|
import { Node, NodeResizer, OnResize, useReactFlow } from "@xyflow/react";
|
|
import { useWorkflowStore } from "@/store/workflowStore";
|
|
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<HTMLDivElement>(null);
|
|
const [trackedSettingsHeight, setTrackedSettingsHeight] = useState(0);
|
|
|
|
// Adjust node height when settings expand/collapse
|
|
useLayoutEffect(() => {
|
|
if (settingsExpanded && settingsPanel) {
|
|
// Expanding: measure settings panel and increase node height
|
|
const measure = () => {
|
|
const panelEl = settingsPanelRef.current;
|
|
if (!panelEl) return;
|
|
const panelHeight = panelEl.scrollHeight;
|
|
if (panelHeight === 0) return;
|
|
|
|
setTrackedSettingsHeight(panelHeight);
|
|
setNodes((nodes) =>
|
|
nodes.map((node) => {
|
|
if (node.id !== id) return node;
|
|
const currentHeight = getNodeDimension(node, "height");
|
|
const newHeight = currentHeight + panelHeight;
|
|
return applyNodeDimensions(node, getNodeDimension(node, "width"), newHeight);
|
|
})
|
|
);
|
|
};
|
|
|
|
// Use rAF to ensure DOM has rendered the panel content
|
|
requestAnimationFrame(measure);
|
|
} else if (!settingsExpanded && trackedSettingsHeight > 0) {
|
|
// Collapsing: decrease node height by the tracked amount
|
|
const heightToRemove = trackedSettingsHeight;
|
|
setTrackedSettingsHeight(0);
|
|
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);
|
|
})
|
|
);
|
|
}
|
|
// 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 - trackedSettingsHeight;
|
|
if (Math.abs(delta) < 2) continue; // Ignore sub-pixel changes
|
|
|
|
setTrackedSettingsHeight(newPanelHeight);
|
|
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);
|
|
})
|
|
);
|
|
}
|
|
});
|
|
|
|
observer.observe(panelEl);
|
|
return () => observer.disconnect();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [settingsExpanded, settingsPanel, trackedSettingsHeight]);
|
|
|
|
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 newSize = calculateAspectFitSize(
|
|
dims.width / dims.height,
|
|
getNodeDimension(thisNode, "width"),
|
|
getNodeDimension(thisNode, "height"),
|
|
fullBleed
|
|
);
|
|
|
|
setNodes((nds) =>
|
|
nds.map((n) => {
|
|
if (n.id === id || (n.selected && n.id !== id)) {
|
|
return applyNodeDimensions(n, newSize.width, newSize.height);
|
|
}
|
|
return n;
|
|
})
|
|
);
|
|
},
|
|
[aspectFitMedia, id, fullBleed, getNodes, setNodes]
|
|
);
|
|
|
|
const hasExpandedSettings = settingsExpanded && settingsPanel;
|
|
|
|
return (
|
|
<div
|
|
className={hasExpandedSettings
|
|
? `relative flex flex-col w-full h-full overflow-visible ${selected ? "ring-2 ring-blue-500/40 shadow-lg shadow-blue-500/25 rounded-lg" : ""}`
|
|
: "contents"}
|
|
onDoubleClick={handleResizeHandleDblClick}
|
|
>
|
|
<NodeResizer
|
|
isVisible={selected}
|
|
minWidth={minWidth}
|
|
minHeight={minHeight}
|
|
lineClassName="!border-transparent"
|
|
handleClassName="!w-3 !h-3 !bg-transparent !border-none"
|
|
onResize={handleResize}
|
|
/>
|
|
<div
|
|
className={`
|
|
${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"} bg-neutral-800/50 border border-neutral-700/40`
|
|
: `bg-neutral-800 ${settingsExpanded ? "rounded-t-lg border-b-0" : "rounded-lg"} shadow-lg border`}
|
|
${fullBleed ? "" : (isCurrentlyExecuting || isExecuting ? "border-blue-500 ring-1 ring-blue-500/20" : "border-neutral-700/60")}
|
|
${fullBleed ? "" : (hasError ? "border-red-500" : "")}
|
|
${fullBleed && selected && !settingsExpanded ? "ring-2 ring-blue-500/40 shadow-lg shadow-blue-500/25" : ""}
|
|
${!fullBleed && selected && !settingsExpanded ? "border-blue-500 ring-2 ring-blue-500/40 shadow-lg shadow-blue-500/25" : ""}
|
|
${!fullBleed && selected && settingsExpanded ? "border-blue-500" : ""}
|
|
${className}
|
|
`}
|
|
onMouseEnter={() => setHoveredNodeId(id)}
|
|
onMouseLeave={() => setHoveredNodeId(null)}
|
|
>
|
|
<div className={contentClassName ?? (fullBleed ? "flex-1 min-h-0 relative" : "px-3 pb-4 flex-1 min-h-0 overflow-hidden flex flex-col")}>{children}</div>
|
|
</div>
|
|
{settingsPanel && (
|
|
<div ref={settingsPanelRef}>
|
|
{settingsPanel}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|