diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e850b17..424d08e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## [1.1.3] - 2026-03-22 + +### Fixed + +- Clamp expand height to minHeight and resolve text through switch nodes +- Move ImageInputNode handles after visual content to prevent z-order clipping +- Add z-index to handles so they paint above positioned node content +- Move overflow-clip from contentClassName to inner visual wrappers to prevent handle clipping +- Move panel height correction from loadWorkflow into BaseNode render +- Prevent node height accumulation with inline parameters on reload +- Update WelcomeModal test to match bg-black/60 backdrop class +- Resolve prompt variables through router nodes for PromptConstructor +- Use overflow-visible on non-fullBleed nodes to prevent handle clipping + +### Other + +- Replace ArrayNode auto-route icon with Lucide split icon + ## [1.1.2] - 2026-03-12 ### Added diff --git a/package.json b/package.json index 63873cb9..a78eb875 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-banana", - "version": "1.1.2", + "version": "1.1.3", "private": true, "scripts": { "dev": "node server.js", diff --git a/src/app/globals.css b/src/app/globals.css index f9b1a6fb..0e6c4a21 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -79,6 +79,7 @@ body { border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + z-index: 5; } /* Larger invisible hit area for easier clicking, especially when zoomed out */ diff --git a/src/components/__tests__/WelcomeModal.test.tsx b/src/components/__tests__/WelcomeModal.test.tsx index 129e0e28..deb5560c 100644 --- a/src/components/__tests__/WelcomeModal.test.tsx +++ b/src/components/__tests__/WelcomeModal.test.tsx @@ -81,7 +81,7 @@ describe("WelcomeModal", () => { /> ); - const backdrop = container.querySelector(".bg-black\\/50"); + const backdrop = container.querySelector(".bg-black\\/60"); expect(backdrop).toBeInTheDocument(); }); }); diff --git a/src/components/nodes/AnnotationNode.tsx b/src/components/nodes/AnnotationNode.tsx index 021271e9..34b9d4dd 100644 --- a/src/components/nodes/AnnotationNode.tsx +++ b/src/components/nodes/AnnotationNode.tsx @@ -96,7 +96,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps ) { - const nodeData = data; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const addNode = useWorkflowStore((state) => state.addNode); const onConnect = useWorkflowStore((state) => state.onConnect); const nodes = useWorkflowStore((state) => state.nodes); const edges = useWorkflowStore((state) => state.edges); + + // Derive nodeData from the Zustand store (already subscribed via `nodes`) + // rather than React Flow props, so settings changes are reflected immediately. + const nodeData = useMemo(() => { + const n = nodes.find((nd) => nd.id === id); + return (n?.data as ArrayNodeData) ?? data; + }, [nodes, id, data]); const { setNodes, getNodes } = useReactFlow(); const lastSyncedInputRef = useRef(null); const lastDerivedWriteRef = useRef(null); @@ -103,11 +109,36 @@ export function ArrayNode({ id, data, selected }: NodeProps) { }); }, [id, nodeData.error, nodeData.outputItems, nodeData.outputText, parsed.error, parsed.items, updateNodeData]); + // Helper: reparse and update outputs atomically whenever any split setting changes. + // Reads fresh data from the Zustand store (not React Flow props) to avoid stale closures. + const updateSettingsAndReparse = useCallback( + (partialSettings: Partial>) => { + const freshNode = useWorkflowStore.getState().nodes.find((n) => n.id === id); + if (!freshNode) return; + const fresh = freshNode.data as ArrayNodeData; + const merged = { + splitMode: partialSettings.splitMode ?? fresh.splitMode, + delimiter: partialSettings.delimiter ?? fresh.delimiter, + regexPattern: partialSettings.regexPattern ?? fresh.regexPattern, + trimItems: partialSettings.trimItems ?? fresh.trimItems, + removeEmpty: partialSettings.removeEmpty ?? fresh.removeEmpty, + }; + const result = parseTextToArray(fresh.inputText, merged); + updateNodeData(id, { + ...partialSettings, + outputItems: result.items, + outputText: JSON.stringify(result.items), + error: result.error, + }); + }, + [id, updateNodeData] + ); + const handleBasicModeChange = useCallback( (e: React.ChangeEvent) => { - updateNodeData(id, { splitMode: e.target.value as ArrayNodeData["splitMode"] }); + updateSettingsAndReparse({ splitMode: e.target.value as ArrayNodeData["splitMode"] }); }, - [id, updateNodeData] + [updateSettingsAndReparse] ); const previewItems = parsed.items; @@ -203,8 +234,11 @@ export function ArrayNode({ id, data, selected }: NodeProps) { className="nodrag nopan absolute top-2 right-2 z-10 shrink-0 p-1 bg-[#1a1a1a] rounded-md text-neutral-400 hover:text-neutral-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" title="Auto-route to Prompts" > - - + + + + + @@ -227,7 +261,7 @@ export function ArrayNode({ id, data, selected }: NodeProps) {
{children}
+
{children}
{settingsPanel && (
diff --git a/src/components/nodes/GLBViewerNode.tsx b/src/components/nodes/GLBViewerNode.tsx index ae6603b1..51d6f189 100644 --- a/src/components/nodes/GLBViewerNode.tsx +++ b/src/components/nodes/GLBViewerNode.tsx @@ -361,7 +361,7 @@ export function GLBViewerNode({ id, data, selected }: NodeProps - {/* Reference input handle for visual links from Split Grid node */} - - {nodeData.image ? ( -
+
{nodeData.filename )} + {/* Handles rendered after visual content so they paint on top */} + ; @@ -31,10 +32,11 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps tags) const availableVariables = useMemo((): AvailableVariable[] => { - const connectedTextNodes = edges + const directTextNodes = edges .filter((e) => e.target === id && e.targetHandle === "text") .map((e) => nodes.find((n) => n.id === e.source)) .filter((n): n is typeof nodes[0] => n !== undefined); + const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, nodes, edges); const vars: AvailableVariable[] = []; const usedNames = new Set(); diff --git a/src/components/nodes/VideoTrimNode.tsx b/src/components/nodes/VideoTrimNode.tsx index 168c9940..85b108d0 100644 --- a/src/components/nodes/VideoTrimNode.tsx +++ b/src/components/nodes/VideoTrimNode.tsx @@ -204,7 +204,7 @@ export function VideoTrimNode({ id, data, selected }: NodeProps diff --git a/src/store/execution/simpleNodeExecutors.ts b/src/store/execution/simpleNodeExecutors.ts index 8adf593a..137c2e72 100644 --- a/src/store/execution/simpleNodeExecutors.ts +++ b/src/store/execution/simpleNodeExecutors.ts @@ -18,6 +18,7 @@ import type { WorkflowNode, } from "@/types"; import type { NodeExecutionContext } from "./types"; +import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs"; import { parseTextToArray } from "@/utils/arrayParser"; import { parseVarTags } from "@/utils/parseVarTags"; @@ -109,11 +110,12 @@ export async function executePromptConstructor(ctx: NodeExecutionContext): Promi const edges = getEdges(); const nodes = getNodes(); - // Find all connected text nodes - const connectedTextNodes = edges + // Find all connected text nodes (resolving through routers) + const directTextNodes = edges .filter((e) => e.target === node.id && e.targetHandle === "text") .map((e) => nodes.find((n) => n.id === e.source)) .filter((n): n is WorkflowNode => n !== undefined); + const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, nodes, edges); // Build variable map: named variables from Prompt nodes take precedence const variableMap: Record = {}; diff --git a/src/store/utils/connectedInputs.ts b/src/store/utils/connectedInputs.ts index a848a380..cf9c7821 100644 --- a/src/store/utils/connectedInputs.ts +++ b/src/store/utils/connectedInputs.ts @@ -119,6 +119,41 @@ function getSourceOutput( return { type: "image", value: null }; } +/** + * Resolves text source nodes through router (passthrough) nodes. + * Given a list of directly-connected source nodes, expands any router nodes + * by recursively following their upstream text connections to find the actual + * text-producing source nodes. + */ +export function resolveTextSourcesThroughRouters( + sourceNodes: WorkflowNode[], + allNodes: WorkflowNode[], + edges: { source: string; target: string; targetHandle?: string | null }[], + visited?: Set +): WorkflowNode[] { + const seen = visited ?? new Set(); + const resolved: WorkflowNode[] = []; + + for (const node of sourceNodes) { + if (seen.has(node.id)) continue; + seen.add(node.id); + + if (node.type === "router" || node.type === "switch") { + const upstreamNodes = edges + .filter((e) => e.target === node.id && e.targetHandle === "text") + .map((e) => allNodes.find((n) => n.id === e.source)) + .filter((n): n is WorkflowNode => n !== undefined); + resolved.push( + ...resolveTextSourcesThroughRouters(upstreamNodes, allNodes, edges, seen) + ); + } else { + resolved.push(node); + } + } + + return resolved; +} + /** * Get all connected inputs for a node. * Pure function version of workflowStore.getConnectedInputs. diff --git a/src/types/nodes.ts b/src/types/nodes.ts index 963c41f7..5a7681c9 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -178,6 +178,7 @@ export interface NanoBananaNodeData extends BaseNodeData { parameters?: Record; // Model-specific parameters for external providers inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles parametersExpanded?: boolean; // Collapse state for inline parameter display + _settingsPanelHeight?: number; // Measured settings panel height for reload correction status: NodeStatus; error: string | null; imageHistory: CarouselImageItem[]; // Carousel history (IDs only) @@ -197,6 +198,7 @@ export interface GenerateVideoNodeData extends BaseNodeData { parameters?: Record; // Model-specific parameters inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles parametersExpanded?: boolean; // Collapse state for inline parameter display + _settingsPanelHeight?: number; // Measured settings panel height for reload correction status: NodeStatus; error: string | null; videoHistory: CarouselVideoItem[]; // Carousel history (IDs only) @@ -217,6 +219,7 @@ export interface Generate3DNodeData extends BaseNodeData { parameters?: Record; inputSchema?: ModelInputDef[]; parametersExpanded?: boolean; // Collapse state for inline parameter display + _settingsPanelHeight?: number; // Measured settings panel height for reload correction status: NodeStatus; error: string | null; } @@ -242,6 +245,7 @@ export interface GenerateAudioNodeData extends BaseNodeData { parameters?: Record; // Model-specific parameters (voice, speed, etc.) inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles parametersExpanded?: boolean; // Collapse state for inline parameter display + _settingsPanelHeight?: number; // Measured settings panel height for reload correction status: NodeStatus; error: string | null; audioHistory: CarouselAudioItem[]; // Carousel history (IDs only) @@ -263,6 +267,7 @@ export interface LLMGenerateNodeData extends BaseNodeData { temperature: number; maxTokens: number; parametersExpanded?: boolean; // Collapse state for inline parameter display + _settingsPanelHeight?: number; // Measured settings panel height for reload correction status: NodeStatus; error: string | null; }