Browse Source

fix: allow node resize from settings panel area

Switch BaseNode wrapper from `display: contents` to flex layout when
settings are expanded so NodeResizer handles span the full visual height.
Height management is now centralized in BaseNode via useLayoutEffect +
ResizeObserver instead of per-node useEffects that compounded height.

- BaseNode: track settings panel height, adjust node dimensions on
  expand/collapse, observe dynamic content changes
- InlineParameterPanel: remove animation (instant show/hide), remove
  selection ring logic (now handled by BaseNode outer container)
- GenerateImageNode/GenerateVideoNode: remove buggy height management
  useEffects and parameterPanelRef
- All 5 node types: remove `selected` prop from InlineParameterPanel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
d6b23fabc5
  1. 99
      src/components/nodes/BaseNode.tsx
  2. 1
      src/components/nodes/Generate3DNode.tsx
  3. 1
      src/components/nodes/GenerateAudioNode.tsx
  4. 35
      src/components/nodes/GenerateImageNode.tsx
  5. 35
      src/components/nodes/GenerateVideoNode.tsx
  6. 37
      src/components/nodes/InlineParameterPanel.tsx
  7. 1
      src/components/nodes/LLMGenerateNode.tsx

99
src/components/nodes/BaseNode.tsx

@ -1,6 +1,6 @@
"use client"; "use client";
import { ReactNode, useCallback } from "react"; import { ReactNode, useCallback, useRef, useLayoutEffect, useState } from "react";
import { Node, NodeResizer, OnResize, useReactFlow } from "@xyflow/react"; import { Node, NodeResizer, OnResize, useReactFlow } from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import { getMediaDimensions, calculateAspectFitSize } from "@/utils/nodeDimensions"; import { getMediaDimensions, calculateAspectFitSize } from "@/utils/nodeDimensions";
@ -74,6 +74,78 @@ export function BaseNode({
const isCurrentlyExecuting = currentNodeIds.includes(id); const isCurrentlyExecuting = currentNodeIds.includes(id);
const { getNodes, setNodes } = useReactFlow(); 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( const handleResize: OnResize = useCallback(
(_event, params) => { (_event, params) => {
setNodes((nodes) => setNodes((nodes) =>
@ -120,8 +192,15 @@ export function BaseNode({
[aspectFitMedia, id, fullBleed, getNodes, setNodes] [aspectFitMedia, id, fullBleed, getNodes, setNodes]
); );
const hasExpandedSettings = settingsExpanded && settingsPanel;
return ( return (
<div className="contents" onDoubleClick={handleResizeHandleDblClick}> <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 <NodeResizer
isVisible={selected} isVisible={selected}
minWidth={minWidth} minWidth={minWidth}
@ -132,7 +211,7 @@ export function BaseNode({
/> />
<div <div
className={` className={`
h-full w-full flex flex-col overflow-visible relative ${hasExpandedSettings ? "flex-1 min-h-0 w-full" : "h-full w-full"} flex flex-col overflow-visible relative
${fullBleed ${fullBleed
? `${settingsExpanded ? "rounded-t-lg border-b-0" : "rounded-lg"} bg-neutral-800/50 border border-neutral-700/40` ? `${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`} : `bg-neutral-800 ${settingsExpanded ? "rounded-t-lg border-b-0" : "rounded-lg"} shadow-lg border`}
@ -146,17 +225,13 @@ export function BaseNode({
onMouseEnter={() => setHoveredNodeId(id)} onMouseEnter={() => setHoveredNodeId(id)}
onMouseLeave={() => setHoveredNodeId(null)} onMouseLeave={() => setHoveredNodeId(null)}
> >
{/* When settings panel is expanded, render the selection ring as a separate clipped overlay
so it doesn't show a line at the bottom the InlineParameterPanel draws the bottom half */}
{settingsExpanded && selected && (
<div
className="absolute -inset-px rounded-t-lg ring-2 ring-blue-500/40 shadow-lg shadow-blue-500/25 pointer-events-none"
style={{ clipPath: 'inset(-20px -20px 1px -20px)' }}
/>
)}
<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 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> </div>
{settingsPanel} {settingsPanel && (
<div ref={settingsPanelRef}>
{settingsPanel}
</div>
)}
</div> </div>
); );
} }

1
src/components/nodes/Generate3DNode.tsx

@ -129,7 +129,6 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
expanded={isParamsExpanded} expanded={isParamsExpanded}
onToggle={handleToggleParams} onToggle={handleToggleParams}
nodeId={id} nodeId={id}
selected={selected}
> >
{/* Model selector: Browse button + current model display */} {/* Model selector: Browse button + current model display */}
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">

1
src/components/nodes/GenerateAudioNode.tsx

@ -242,7 +242,6 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
expanded={isParamsExpanded} expanded={isParamsExpanded}
onToggle={handleToggleParams} onToggle={handleToggleParams}
nodeId={id} nodeId={id}
selected={selected}
> >
{/* Model selector: Browse button + current model display */} {/* Model selector: Browse button + current model display */}
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">

35
src/components/nodes/GenerateImageNode.tsx

@ -52,7 +52,6 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
// Inline parameters infrastructure // Inline parameters infrastructure
const { inlineParametersEnabled } = useInlineParameters(); const { inlineParametersEnabled } = useInlineParameters();
const parameterPanelRef = useRef<HTMLDivElement>(null);
// Get the current selected provider (default to gemini) // Get the current selected provider (default to gemini)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "gemini"; const currentProvider: ProviderType = nodeData.selectedModel?.provider || "gemini";
@ -495,39 +494,6 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
}); });
}, [id, nodeData.outputImage, setNodes]); }, [id, nodeData.outputImage, setNodes]);
// Node height management for inline parameters
useEffect(() => {
if (!inlineParametersEnabled) return;
// Use RAF to wait for render
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const currentNode = useWorkflowStore.getState().nodes.find(n => n.id === id);
if (!currentNode) return;
const baseHeight = typeof currentNode.style?.height === 'number'
? currentNode.style.height
: 300;
let paramHeight = 0;
if (isParamsExpanded && parameterPanelRef.current) {
// Measure the actual rendered height of parameters
const panelContent = parameterPanelRef.current.querySelector('#params-' + id);
if (panelContent) {
paramHeight = (panelContent as HTMLElement).scrollHeight;
}
}
// Add chevron button height (~28px)
const chevronHeight = 28;
const totalHeight = baseHeight + chevronHeight + paramHeight;
setNodes(nodes => nodes.map(n =>
n.id === id ? { ...n, style: { ...n.style, height: totalHeight } } : n
));
});
});
}, [id, isParamsExpanded, inlineParametersEnabled, setNodes]);
return ( return (
<> <>
<BaseNode <BaseNode
@ -543,7 +509,6 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
expanded={isParamsExpanded} expanded={isParamsExpanded}
onToggle={handleToggleParams} onToggle={handleToggleParams}
nodeId={id} nodeId={id}
selected={selected}
> >
{/* Gemini-specific controls */} {/* Gemini-specific controls */}
{isGeminiProvider && currentModelId && ( {isGeminiProvider && currentModelId && (

35
src/components/nodes/GenerateVideoNode.tsx

@ -57,7 +57,6 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
// Inline parameters infrastructure // Inline parameters infrastructure
const { inlineParametersEnabled } = useInlineParameters(); const { inlineParametersEnabled } = useInlineParameters();
const parameterPanelRef = useRef<HTMLDivElement>(null);
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal"; const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
@ -403,39 +402,6 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
}); });
}, [id, nodeData.outputVideo, setNodes]); }, [id, nodeData.outputVideo, setNodes]);
// Node height management for inline parameters
useEffect(() => {
if (!inlineParametersEnabled) return;
// Use RAF to wait for render
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const currentNode = useWorkflowStore.getState().nodes.find(n => n.id === id);
if (!currentNode) return;
const baseHeight = typeof currentNode.style?.height === 'number'
? currentNode.style.height
: 300;
let paramHeight = 0;
if (isParamsExpanded && parameterPanelRef.current) {
// Measure the actual rendered height of parameters
const panelContent = parameterPanelRef.current.querySelector('#params-' + id);
if (panelContent) {
paramHeight = (panelContent as HTMLElement).scrollHeight;
}
}
// Add chevron button height (~28px)
const chevronHeight = 28;
const totalHeight = baseHeight + chevronHeight + paramHeight;
setNodes(nodes => nodes.map(n =>
n.id === id ? { ...n, style: { ...n.style, height: totalHeight } } : n
));
});
});
}, [id, isParamsExpanded, inlineParametersEnabled, setNodes]);
return ( return (
<> <>
<BaseNode <BaseNode
@ -451,7 +417,6 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
expanded={isParamsExpanded} expanded={isParamsExpanded}
onToggle={handleToggleParams} onToggle={handleToggleParams}
nodeId={id} nodeId={id}
selected={selected}
> >
{/* External provider parameters - reuse ModelParameters component */} {/* External provider parameters - reuse ModelParameters component */}
{nodeData.selectedModel?.modelId && !isVeoModel(nodeData.selectedModel.modelId) && ( {nodeData.selectedModel?.modelId && !isVeoModel(nodeData.selectedModel.modelId) && (

37
src/components/nodes/InlineParameterPanel.tsx

@ -7,31 +7,20 @@ interface InlineParameterPanelProps {
onToggle: () => void; onToggle: () => void;
children: ReactNode; children: ReactNode;
nodeId: string; nodeId: string;
selected?: boolean;
} }
/** /**
* Collapsible parameter container for inline display within generation nodes. * Collapsible parameter container for inline display within generation nodes.
* Provides a chevron toggle button and smooth expand/collapse transitions. * Provides a chevron toggle button and instant expand/collapse.
*/ */
export function InlineParameterPanel({ export function InlineParameterPanel({
expanded, expanded,
onToggle, onToggle,
children, children,
nodeId, nodeId,
selected = false,
}: InlineParameterPanelProps) { }: InlineParameterPanelProps) {
// When expanded + selected, draw ring on bottom half and clip the top edge
// so it merges seamlessly with BaseNode's ring (which clips its bottom edge)
const ringClass = expanded && selected
? "ring-2 ring-blue-500/40 rounded-b-lg"
: "";
const ringStyle = expanded && selected
? { clipPath: 'inset(0 -20px -20px -20px)' }
: undefined;
return ( return (
<div className={`w-full ${ringClass}`} style={ringStyle}> <>
{/* Settings toggle button — no background when collapsed, floats below node edge */} {/* Settings toggle button — no background when collapsed, floats below node edge */}
<button <button
type="button" type="button"
@ -56,19 +45,15 @@ export function InlineParameterPanel({
</svg> </svg>
</button> </button>
{/* Collapsible content area */} {/* Content area — instant show/hide */}
<div {expanded && (
id={`params-${nodeId}`} <div
className="overflow-hidden transition-all duration-200" id={`params-${nodeId}`}
style={{ className="nodrag nopan nowheel bg-[#2a2a2a] px-3 pt-2 pb-3 rounded-b-lg"
maxHeight: expanded ? "2000px" : "0", >
opacity: expanded ? 1 : 0, {children}
}}
>
<div className="nodrag nopan nowheel bg-[#2a2a2a] px-3 pt-2 pb-3 rounded-b-lg">
<div className="space-y-1.5 max-w-[280px] mx-auto">{children}</div>
</div> </div>
</div> )}
</div> </>
); );
} }

1
src/components/nodes/LLMGenerateNode.tsx

@ -131,7 +131,6 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
expanded={isParamsExpanded} expanded={isParamsExpanded}
onToggle={handleToggleParams} onToggle={handleToggleParams}
nodeId={id} nodeId={id}
selected={selected}
> >
{/* LLM-specific controls */} {/* LLM-specific controls */}
<div className="space-y-1.5"> <div className="space-y-1.5">

Loading…
Cancel
Save