From ceaeddc0a6671ee5cd509f53cb7c5fd834f43387 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 11 Mar 2026 22:50:09 +1300 Subject: [PATCH] =?UTF-8?q?fix:=20UI=20polish=20=E2=80=94=20settings=20ani?= =?UTF-8?q?mation,=20grid=20layout,=20prompt=20bar,=20split=20grid=20fit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/nodes/BaseNode.tsx | 75 +++++++++++++-- src/components/nodes/GenerateImageNode.tsx | 103 ++++++++++++++++----- src/components/nodes/LLMGenerateNode.tsx | 2 +- src/components/nodes/PromptNode.tsx | 18 ++-- src/components/nodes/SplitGridNode.tsx | 3 +- 5 files changed, 161 insertions(+), 40 deletions(-) diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index 74b1601e..fa1a6b7b 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -77,15 +77,27 @@ export function BaseNode({ const settingsPanelRef = useRef(null); const contentRef = useRef(null); const trackedSettingsHeightRef = useRef(0); + const isAnimatingRef = useRef(false); + const animationTimeoutRef = useRef | 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) => diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 0490e454..b61308af 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/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(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 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(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 {/* Gemini-specific controls */} - {isGeminiProvider && currentModelId && ( -
- {/* Model selector */} -
+ {isGeminiProvider && currentModelId && (() => { + const controls: React.ReactNode[] = [ +
-
- - {/* Aspect Ratio */} -
+
, +
-
+
, + ]; - {/* Resolution (if supported) */} - {supportsResolution && ( -
+ if (supportsResolution) { + controls.push( +
Google Search - )} + ); + } - {/* Image Search toggle (NB2 only) */} - {currentModelId === "nano-banana-2" && ( -
- )} + ); + } + + const display = useGeminiGrid && geminiColCount > 1 + ? reorderColumnFirst(controls, geminiColCount) + : controls; + + return ( +
+ {display} +
+ ); + })()} {/* External provider parameters - reuse ModelParameters component */} {!isGeminiProvider && nodeData.selectedModel?.modelId && ( diff --git a/src/components/nodes/LLMGenerateNode.tsx b/src/components/nodes/LLMGenerateNode.tsx index a82d0db7..da4e0278 100644 --- a/src/components/nodes/LLMGenerateNode.tsx +++ b/src/components/nodes/LLMGenerateNode.tsx @@ -130,7 +130,7 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps {/* LLM-specific controls */} -
+
{/* Provider */}
diff --git a/src/components/nodes/PromptNode.tsx b/src/components/nodes/PromptNode.tsx index 54520f1c..13faeec8 100644 --- a/src/components/nodes/PromptNode.tsx +++ b/src/components/nodes/PromptNode.tsx @@ -110,15 +110,17 @@ export function PromptNode({ id, data, selected }: NodeProps) { 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" /> - +
+ +
{/* Text output handle */} {/* Image input handle */} {/* Grid overlay visualization */}