Browse Source

fix: UI polish — settings animation, grid layout, prompt bar, split grid fit

Improve settings panel expand/collapse with animation-aware resize
suppression. Refactor Gemini controls to responsive grid layout with
column-first reordering. Add max-width to LLM controls. Move prompt
variable button to a bottom bar with backdrop blur. Use object-contain
for SplitGrid source image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
ceaeddc0a6
  1. 75
      src/components/nodes/BaseNode.tsx
  2. 103
      src/components/nodes/GenerateImageNode.tsx
  3. 2
      src/components/nodes/LLMGenerateNode.tsx
  4. 18
      src/components/nodes/PromptNode.tsx
  5. 3
      src/components/nodes/SplitGridNode.tsx

75
src/components/nodes/BaseNode.tsx

@ -77,15 +77,27 @@ export function BaseNode({
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 collapse
// 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 to prevent image flicker during resize
const contentEl = contentRef.current;
// Lock content height for the full animation duration
if (contentEl) {
contentEl.style.height = contentEl.offsetHeight + "px";
}
@ -99,12 +111,48 @@ export function BaseNode({
})
);
// Release locked height after layout settles
requestAnimationFrame(() => {
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
const finalHeight = trackedSettingsHeightRef.current;
if (finalHeight > 0) {
setNodes((nodes) =>
nodes.map((node) => {
if (node.id !== id) return node;
const currentHeight = getNodeDimension(node, "height");
return applyNodeDimensions(node, getNodeDimension(node, "width"), currentHeight + finalHeight);
})
);
}
if (contentEl) {
contentEl.style.height = "";
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]);
@ -124,6 +172,10 @@ export function BaseNode({
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) {
@ -153,6 +205,15 @@ export function BaseNode({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsExpanded, settingsPanel]);
// Cleanup animation timeout on unmount
useLayoutEffect(() => {
return () => {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}
};
}, []);
const handleResize: OnResize = useCallback(
(_event, params) => {
setNodes((nodes) =>

103
src/components/nodes/GenerateImageNode.tsx

@ -16,6 +16,20 @@ import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { InlineParameterPanel } from "./InlineParameterPanel";
/** Reorder items so they read column-first in a row-based CSS grid.
* e.g. [1,2,3,4,5,6,7,8] with 2 cols [1,5,2,6,3,7,4,8] */
function reorderColumnFirst<T>(items: T[], cols: number): T[] {
const rows = Math.ceil(items.length / cols);
const result: T[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const idx = c * rows + r;
if (idx < items.length) result.push(items[idx]);
}
}
return result;
}
// Base 10 aspect ratios (all Gemini image models)
const BASE_ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
@ -445,6 +459,33 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
const resolutions = currentModelId === "nano-banana-2" ? RESOLUTIONS_NB2 : RESOLUTIONS_PRO;
const hasCarouselImages = (nodeData.imageHistory || []).length > 1;
// Count visible Gemini controls to match ModelParameters grid/max-width rules
const geminiControlCount = 2 // Model + Aspect Ratio (always)
+ (supportsResolution ? 1 : 0)
+ (currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2" ? 1 : 0)
+ (currentModelId === "nano-banana-2" ? 1 : 0);
const useGeminiGrid = geminiControlCount > 4;
const geminiGridRef = useRef<HTMLDivElement>(null);
const [geminiColCount, setGeminiColCount] = useState(1);
useEffect(() => {
const el = geminiGridRef.current;
if (!el || !useGeminiGrid) { setGeminiColCount(1); return; }
let rafId: number;
const observer = new ResizeObserver(() => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const cols = getComputedStyle(el).gridTemplateColumns.split(" ").length;
setGeminiColCount(prev => prev === cols ? prev : cols);
});
});
observer.observe(el);
return () => {
cancelAnimationFrame(rafId);
observer.disconnect();
};
}, [useGeminiGrid]);
// Track previous status to detect error transitions
const prevStatusRef = useRef(nodeData.status);
@ -508,10 +549,9 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
nodeId={id}
>
{/* Gemini-specific controls */}
{isGeminiProvider && currentModelId && (
<div className="space-y-1.5">
{/* Model selector */}
<div className="flex items-center gap-2">
{isGeminiProvider && currentModelId && (() => {
const controls: React.ReactNode[] = [
<div key="model" className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Model</label>
<select
value={currentModelId}
@ -524,10 +564,8 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
</option>
))}
</select>
</div>
{/* Aspect Ratio */}
<div className="flex items-center gap-2">
</div>,
<div key="aspect-ratio" className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Aspect Ratio</label>
<select
value={nodeData.aspectRatio || "1:1"}
@ -540,11 +578,12 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
</option>
))}
</select>
</div>
</div>,
];
{/* Resolution (if supported) */}
{supportsResolution && (
<div className="flex items-center gap-2">
if (supportsResolution) {
controls.push(
<div key="resolution" className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Resolution</label>
<select
value={nodeData.resolution || "2K"}
@ -558,11 +597,12 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
))}
</select>
</div>
)}
);
}
{/* Google Search toggle */}
{(currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2") && (
<label className="flex items-center gap-1.5 text-[11px] text-neutral-300 cursor-pointer">
if (currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2") {
controls.push(
<label key="google-search" className="flex items-center gap-1.5 text-[11px] text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={nodeData.useGoogleSearch || false}
@ -571,11 +611,12 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
/>
Google Search
</label>
)}
);
}
{/* Image Search toggle (NB2 only) */}
{currentModelId === "nano-banana-2" && (
<label className="flex items-center gap-1.5 text-[11px] text-neutral-300 cursor-pointer">
if (currentModelId === "nano-banana-2") {
controls.push(
<label key="image-search" className="flex items-center gap-1.5 text-[11px] text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={nodeData.useImageSearch || false}
@ -584,9 +625,25 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
/>
Image Search
</label>
)}
</div>
)}
);
}
const display = useGeminiGrid && geminiColCount > 1
? reorderColumnFirst(controls, geminiColCount)
: controls;
return (
<div
ref={geminiGridRef}
className={useGeminiGrid
? "grid grid-cols-[repeat(auto-fill,minmax(min(180px,100%),1fr))] max-w-[420px] gap-x-6 gap-y-1.5"
: "space-y-1.5 max-w-[280px]"
}
>
{display}
</div>
);
})()}
{/* External provider parameters - reuse ModelParameters component */}
{!isGeminiProvider && nodeData.selectedModel?.modelId && (

2
src/components/nodes/LLMGenerateNode.tsx

@ -130,7 +130,7 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
nodeId={id}
>
{/* LLM-specific controls */}
<div className="space-y-1.5">
<div className="space-y-1.5 max-w-[280px]">
{/* Provider */}
<div className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Provider</label>

18
src/components/nodes/PromptNode.tsx

@ -110,15 +110,17 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={hasIncomingTextConnection ? "Text from connected node (editable)..." : "Describe what to generate..."}
className="nodrag nopan nowheel w-full h-full p-3 text-xs leading-relaxed text-neutral-100 bg-neutral-800 rounded-lg resize-none focus:outline-none placeholder:text-neutral-500"
className="nodrag nopan nowheel w-full h-full p-3 pb-7 text-xs leading-relaxed text-neutral-100 bg-neutral-800 rounded-t-lg resize-none focus:outline-none placeholder:text-neutral-500"
/>
<button
onClick={() => setShowVarDialog(true)}
className="nodrag nopan absolute bottom-2 left-3 z-10 text-[10px] text-blue-400 hover:text-blue-300 transition-colors"
title="Set variable name"
>
{nodeData.variableName ? `@${nodeData.variableName}` : "Add variable"}
</button>
<div className="absolute bottom-0 left-0 right-0 z-10 px-3 py-1.5 bg-neutral-900/80 backdrop-blur-sm rounded-b-lg">
<button
onClick={() => setShowVarDialog(true)}
className="nodrag nopan text-[10px] text-blue-400 hover:text-blue-300 transition-colors"
title="Set variable name"
>
{nodeData.variableName ? `@${nodeData.variableName}` : "Add variable"}
</button>
</div>
{/* Text output handle */}
<Handle

3
src/components/nodes/SplitGridNode.tsx

@ -42,6 +42,7 @@ export function SplitGridNode({ id, data, selected }: NodeProps<SplitGridNodeTyp
selected={selected}
hasError={nodeData.status === "error"}
fullBleed
aspectFitMedia={nodeData.sourceImage}
>
{/* Image input handle */}
<Handle
@ -68,7 +69,7 @@ export function SplitGridNode({ id, data, selected }: NodeProps<SplitGridNodeTyp
<img
src={nodeData.sourceImage}
alt="Source grid"
className="w-full h-full object-cover rounded-lg"
className="w-full h-full object-contain rounded-lg"
/>
{/* Grid overlay visualization */}
<div

Loading…
Cancel
Save