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. 97
      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. 31
      src/components/nodes/InlineParameterPanel.tsx
  7. 1
      src/components/nodes/LLMGenerateNode.tsx

97
src/components/nodes/BaseNode.tsx

@ -1,6 +1,6 @@
"use client";
import { ReactNode, useCallback } from "react";
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";
@ -74,6 +74,78 @@ export function BaseNode({
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) =>
@ -120,8 +192,15 @@ export function BaseNode({
[aspectFitMedia, id, fullBleed, getNodes, setNodes]
);
const hasExpandedSettings = settingsExpanded && settingsPanel;
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
isVisible={selected}
minWidth={minWidth}
@ -132,7 +211,7 @@ export function BaseNode({
/>
<div
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
? `${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`}
@ -146,17 +225,13 @@ export function BaseNode({
onMouseEnter={() => setHoveredNodeId(id)}
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>
{settingsPanel && (
<div ref={settingsPanelRef}>
{settingsPanel}
</div>
)}
</div>
);
}

1
src/components/nodes/Generate3DNode.tsx

@ -129,7 +129,6 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
expanded={isParamsExpanded}
onToggle={handleToggleParams}
nodeId={id}
selected={selected}
>
{/* Model selector: Browse button + current model display */}
<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}
onToggle={handleToggleParams}
nodeId={id}
selected={selected}
>
{/* Model selector: Browse button + current model display */}
<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
const { inlineParametersEnabled } = useInlineParameters();
const parameterPanelRef = useRef<HTMLDivElement>(null);
// Get the current selected provider (default to gemini)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "gemini";
@ -495,39 +494,6 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
});
}, [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 (
<>
<BaseNode
@ -543,7 +509,6 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
expanded={isParamsExpanded}
onToggle={handleToggleParams}
nodeId={id}
selected={selected}
>
{/* Gemini-specific controls */}
{isGeminiProvider && currentModelId && (

35
src/components/nodes/GenerateVideoNode.tsx

@ -57,7 +57,6 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
// Inline parameters infrastructure
const { inlineParametersEnabled } = useInlineParameters();
const parameterPanelRef = useRef<HTMLDivElement>(null);
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
@ -403,39 +402,6 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
});
}, [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 (
<>
<BaseNode
@ -451,7 +417,6 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
expanded={isParamsExpanded}
onToggle={handleToggleParams}
nodeId={id}
selected={selected}
>
{/* External provider parameters - reuse ModelParameters component */}
{nodeData.selectedModel?.modelId && !isVeoModel(nodeData.selectedModel.modelId) && (

31
src/components/nodes/InlineParameterPanel.tsx

@ -7,31 +7,20 @@ interface InlineParameterPanelProps {
onToggle: () => void;
children: ReactNode;
nodeId: string;
selected?: boolean;
}
/**
* 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({
expanded,
onToggle,
children,
nodeId,
selected = false,
}: 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 (
<div className={`w-full ${ringClass}`} style={ringStyle}>
<>
{/* Settings toggle button — no background when collapsed, floats below node edge */}
<button
type="button"
@ -56,19 +45,15 @@ export function InlineParameterPanel({
</svg>
</button>
{/* Collapsible content area */}
{/* Content area — instant show/hide */}
{expanded && (
<div
id={`params-${nodeId}`}
className="overflow-hidden transition-all duration-200"
style={{
maxHeight: expanded ? "2000px" : "0",
opacity: expanded ? 1 : 0,
}}
className="nodrag nopan nowheel bg-[#2a2a2a] px-3 pt-2 pb-3 rounded-b-lg"
>
<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>
{children}
</div>
)}
</>
);
}

1
src/components/nodes/LLMGenerateNode.tsx

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

Loading…
Cancel
Save