Browse Source

Merge pull request #103 from shrimbly/develop

Release v1.1.3
handoff-20260429-1057
Willie 4 months ago
committed by GitHub
parent
commit
db352a494d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 18
      CHANGELOG.md
  2. 2
      package.json
  3. 1
      src/app/globals.css
  4. 2
      src/components/__tests__/WelcomeModal.test.tsx
  5. 4
      src/components/nodes/AnnotationNode.tsx
  6. 52
      src/components/nodes/ArrayNode.tsx
  7. 26
      src/components/nodes/BaseNode.tsx
  8. 2
      src/components/nodes/GLBViewerNode.tsx
  9. 21
      src/components/nodes/ImageInputNode.tsx
  10. 4
      src/components/nodes/PromptConstructorNode.tsx
  11. 2
      src/components/nodes/VideoTrimNode.tsx
  12. 6
      src/store/execution/simpleNodeExecutors.ts
  13. 35
      src/store/utils/connectedInputs.ts
  14. 5
      src/types/nodes.ts

18
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

2
package.json

@ -1,6 +1,6 @@
{
"name": "node-banana",
"version": "1.1.2",
"version": "1.1.3",
"private": true,
"scripts": {
"dev": "node server.js",

1
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 */

2
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();
});
});

4
src/components/nodes/AnnotationNode.tsx

@ -96,7 +96,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
<BaseNode
id={id}
selected={selected}
contentClassName="flex-1 min-h-0 overflow-clip"
contentClassName="flex-1 min-h-0"
aspectFitMedia={nodeData.outputImage}
>
<input
@ -122,7 +122,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
{displayImage ? (
<div
className="relative group cursor-pointer w-full h-full"
className="relative group cursor-pointer w-full h-full overflow-clip rounded-lg"
onClick={handleEdit}
>
<img

52
src/components/nodes/ArrayNode.tsx

@ -19,12 +19,18 @@ function arraysEqual(a: string[], b: string[]): boolean {
}
export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
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<string | null>(null);
const lastDerivedWriteRef = useRef<string | null>(null);
@ -103,11 +109,36 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
});
}, [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<Pick<ArrayNodeData, "splitMode" | "delimiter" | "regexPattern" | "trimItems" | "removeEmpty">>) => {
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<HTMLSelectElement>) => {
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<ArrayNodeType>) {
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"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 3v6m0 0a2 2 0 104 0 2 2 0 00-4 0zm0 0v7a3 3 0 003 3h6m0 0a2 2 0 100 4 2 2 0 000-4zm0 0v-6a3 3 0 013-3h0" />
<svg className="w-3.5 h-3.5 rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M16 3h5v5" />
<path d="M8 3H3v5" />
<path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" />
<path d="m15 9 6-6" />
</svg>
</button>
@ -227,7 +261,7 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
<label className="shrink-0 text-[11px] text-neutral-400">By</label>
<input
value={nodeData.delimiter}
onChange={(e) => updateNodeData(id, { delimiter: e.target.value })}
onChange={(e) => updateSettingsAndReparse({ delimiter: e.target.value })}
placeholder="*"
className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 bg-[#1a1a1a] rounded-md focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white"
/>
@ -239,7 +273,7 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
<label className="shrink-0 text-[11px] text-neutral-400">By</label>
<input
value={nodeData.regexPattern}
onChange={(e) => updateNodeData(id, { regexPattern: e.target.value })}
onChange={(e) => updateSettingsAndReparse({ regexPattern: e.target.value })}
placeholder="/\\n+/"
className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 bg-[#1a1a1a] rounded-md focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white"
/>
@ -264,7 +298,7 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
<input
type="checkbox"
checked={nodeData.trimItems}
onChange={(e) => updateNodeData(id, { trimItems: e.target.checked })}
onChange={(e) => updateSettingsAndReparse({ trimItems: e.target.checked })}
className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
/>
Trim
@ -273,7 +307,7 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
<input
type="checkbox"
checked={nodeData.removeEmpty}
onChange={(e) => updateNodeData(id, { removeEmpty: e.target.checked })}
onChange={(e) => updateSettingsAndReparse({ removeEmpty: e.target.checked })}
className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
/>
Remove empty

26
src/components/nodes/BaseNode.tsx

@ -108,7 +108,10 @@ export function BaseNode({
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);
return {
...applyNodeDimensions(node, getNodeDimension(node, "width"), newHeight),
data: { ...node.data, _settingsPanelHeight: 0 },
};
})
);
@ -134,14 +137,24 @@ export function BaseNode({
animationTimeoutRef.current = setTimeout(() => {
isAnimatingRef.current = false;
// Apply the final panel height in one shot, then unlock the wrapper
// Apply the final panel height in one shot, then unlock the wrapper.
// Subtract any previously saved panel height to avoid double-counting
// on workflow reload (saved node height already includes panel).
const finalHeight = trackedSettingsHeightRef.current;
if (finalHeight > 0) {
setNodes((nodes) =>
nodes.map((node) => {
if (node.id !== id) return node;
const savedPanelHeight = typeof (node.data as Record<string, unknown>)?._settingsPanelHeight === "number"
? (node.data as Record<string, unknown>)._settingsPanelHeight as number
: 0;
const heightToAdd = finalHeight - savedPanelHeight;
const currentHeight = getNodeDimension(node, "height");
return applyNodeDimensions(node, getNodeDimension(node, "width"), currentHeight + finalHeight);
const newHeight = Math.max(minHeight, currentHeight + heightToAdd);
return {
...applyNodeDimensions(node, getNodeDimension(node, "width"), newHeight),
data: { ...node.data, _settingsPanelHeight: finalHeight },
};
})
);
}
@ -188,7 +201,10 @@ export function BaseNode({
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);
return {
...applyNodeDimensions(node, getNodeDimension(node, "width"), newHeight),
data: { ...node.data, _settingsPanelHeight: newPanelHeight },
};
})
);
@ -305,7 +321,7 @@ export function BaseNode({
setHoveredNodeId(null);
}}
>
<div ref={contentRef} style={{ contain: "layout style" }} 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 ref={contentRef} style={{ contain: "layout style" }} className={contentClassName ?? (fullBleed ? "flex-1 min-h-0 relative" : "px-3 pb-4 flex-1 min-h-0 overflow-visible flex flex-col")}>{children}</div>
</div>
{settingsPanel && (
<div ref={settingsPanelRef}>

2
src/components/nodes/GLBViewerNode.tsx

@ -361,7 +361,7 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
<BaseNode
id={id}
selected={selected}
contentClassName={nodeData.glbUrl ? "flex-1 min-h-0 overflow-hidden flex flex-col" : undefined}
contentClassName={nodeData.glbUrl ? "flex-1 min-h-0 flex flex-col" : undefined}
aspectFitMedia={nodeData.capturedImage}
>
<input

21
src/components/nodes/ImageInputNode.tsx

@ -87,19 +87,10 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
<BaseNode
id={id}
selected={selected}
contentClassName="flex-1 min-h-0 overflow-clip"
contentClassName="flex-1 min-h-0"
aspectFitMedia={nodeData.image}
fullBleed
>
{/* Reference input handle for visual links from Split Grid node */}
<Handle
type="target"
position={Position.Left}
id="reference"
data-handletype="reference"
className="!bg-gray-500"
/>
<input
ref={fileInputRef}
type="file"
@ -109,7 +100,7 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
/>
{nodeData.image ? (
<div className="relative group w-full h-full">
<div className="relative group w-full h-full overflow-clip rounded-lg">
<img
src={adaptiveImage ?? undefined}
alt={nodeData.filename || "Uploaded image"}
@ -148,6 +139,14 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
</div>
)}
{/* Handles rendered after visual content so they paint on top */}
<Handle
type="target"
position={Position.Left}
id="reference"
data-handletype="reference"
className="!bg-gray-500"
/>
<Handle
type="source"
position={Position.Right}

4
src/components/nodes/PromptConstructorNode.tsx

@ -6,6 +6,7 @@ import { BaseNode } from "./BaseNode";
import { usePromptAutocomplete } from "@/hooks/usePromptAutocomplete";
import { useWorkflowStore } from "@/store/workflowStore";
import { PromptConstructorNodeData, PromptNodeData, LLMGenerateNodeData, AvailableVariable } from "@/types";
import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs";
import { parseVarTags } from "@/utils/parseVarTags";
type PromptConstructorNodeType = Node<PromptConstructorNodeData, "promptConstructor">;
@ -31,10 +32,11 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
// Get available variables from connected prompt nodes (named variables + inline <var> 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<string>();

2
src/components/nodes/VideoTrimNode.tsx

@ -204,7 +204,7 @@ export function VideoTrimNode({ id, data, selected }: NodeProps<VideoTrimNodeTyp
<BaseNode
id={id}
selected={selected}
contentClassName="flex-1 min-h-0 overflow-clip"
contentClassName="flex-1 min-h-0"
minWidth={360}
minHeight={360}
>

6
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<string, string> = {};

35
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<string>
): WorkflowNode[] {
const seen = visited ?? new Set<string>();
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.

5
src/types/nodes.ts

@ -178,6 +178,7 @@ export interface NanoBananaNodeData extends BaseNodeData {
parameters?: Record<string, unknown>; // 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<string, unknown>; // 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<string, unknown>;
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<string, unknown>; // 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;
}

Loading…
Cancel
Save