1
0
Fork 0

fix: restore small handle labels and make Format node dynamic

- TypedHandle now shows tiny 8px labels next to handles (color-matched)
- Labels positioned outside the handle, no overlap with node content
- Format node dynamically generates input handles from template {0}, {1}, etc.
- NodeHandles accepts dynamicInputs/dynamicOutputs overrides
- isValidConnection treats dynamic handles (arg-*, option-*) as any-type
This commit is contained in:
Jason Fraley 2026-07-28 18:22:25 -04:00
parent 8ec0c194ff
commit 707562faaa
4 changed files with 68 additions and 24 deletions

View file

@ -3,13 +3,30 @@
import { type NodeProps } from "@xyflow/react";
import { AlignLeftIcon } from "lucide-react";
import { NodeHandles } from "./NodeHandles";
import { NODE_SOCKETS, type SocketDef } from "./types";
function parseTemplateArgs(template: string): SocketDef[] {
const matches = template.match(/\{(\d+)\}/g);
if (!matches) return [];
const unique = [...new Set(matches)].sort();
return unique.map((m) => ({
id: `arg-${m.slice(1, -1)}`,
type: "any" as const,
label: m,
}));
}
export function FormatNode({ data }: NodeProps) {
const d = data as { template?: string };
const template = d.template ?? "{0}";
const dynamicInputs: SocketDef[] = [
...(NODE_SOCKETS.format?.inputs.filter((s) => s.id === "template") ?? []),
...parseTemplateArgs(template),
];
return (
<div className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40">
<NodeHandles nodeType="format" />
<NodeHandles nodeType="format" dynamicInputs={dynamicInputs} />
<div className="flex items-center gap-2 px-3 py-2 border-b border-sky-500/30">
<AlignLeftIcon className="size-4 text-sky-400 shrink-0" />
<span className="text-sm font-medium text-sky-200 flex-1">
@ -17,7 +34,7 @@ export function FormatNode({ data }: NodeProps) {
</span>
</div>
<div className="px-3 py-2 text-xs text-sky-300/80 font-mono truncate">
{d.template || "{0} {1}"}
{template}
</div>
</div>
);

View file

@ -119,10 +119,14 @@ export function NarrativeFlowEditor() {
const sourceSocket = sourceSockets.outputs.find((s) => s.id === connection.sourceHandle);
const targetSocket = targetSockets.inputs.find((s) => s.id === connection.targetHandle);
if (!sourceSocket || !targetSocket) return false;
if (sourceSocket.type === "any" || targetSocket.type === "any") return true;
return sourceSocket.type === targetSocket.type;
// Dynamic handles (arg-*, option-*) are treated as "any"
const sourceType = sourceSocket?.type ?? (connection.sourceHandle.startsWith("arg-") || connection.sourceHandle.startsWith("option-") ? "any" : null);
const targetType = targetSocket?.type ?? (connection.targetHandle.startsWith("arg-") || connection.targetHandle.startsWith("option-") ? "any" : null);
if (sourceType === null || targetType === null) return false;
if (sourceType === "any" || targetType === "any") return true;
return sourceType === targetType;
},
[nodes],
);

View file

@ -1,21 +1,20 @@
"use client";
import { TypedHandle } from "./TypedHandle";
import { NODE_SOCKETS } from "./types";
import { NODE_SOCKETS, type SocketDef } from "./types";
interface NodeHandlesProps {
nodeType: string;
dynamicOutputs?: Array<{ id: string }>;
dynamicInputs?: SocketDef[];
dynamicOutputs?: SocketDef[];
}
export function NodeHandles({ nodeType, dynamicOutputs }: NodeHandlesProps) {
export function NodeHandles({ nodeType, dynamicInputs, dynamicOutputs }: NodeHandlesProps) {
const config = NODE_SOCKETS[nodeType];
if (!config) return null;
const inputs = config.inputs;
const outputs = dynamicOutputs
? dynamicOutputs.map((o) => ({ ...o, type: "flow" as const }))
: config.outputs;
const inputs = dynamicInputs ?? config.inputs;
const outputs = dynamicOutputs ?? config.outputs;
const maxSlots = Math.max(inputs.length, outputs.length, 1);
@ -28,6 +27,7 @@ export function NodeHandles({ nodeType, dynamicOutputs }: NodeHandlesProps) {
key={socket.id}
id={socket.id}
socketType={socket.type}
label={socket.label}
isInput={true}
style={{ top: `${topPercent}%` }}
/>
@ -40,6 +40,7 @@ export function NodeHandles({ nodeType, dynamicOutputs }: NodeHandlesProps) {
key={socket.id}
id={socket.id}
socketType={socket.type}
label={socket.label}
isInput={false}
style={{ top: `${topPercent}%` }}
/>

View file

@ -7,26 +7,48 @@ interface TypedHandleProps {
id: string;
socketType: SocketType;
isInput: boolean;
label?: string;
style?: React.CSSProperties;
}
export function TypedHandle({ id, socketType, isInput, style }: TypedHandleProps) {
export function TypedHandle({ id, socketType, isInput, label, style }: TypedHandleProps) {
const color = SOCKET_COLORS[socketType];
const isFlow = socketType === "flow";
return (
<Handle
type={isInput ? "target" : "source"}
position={isInput ? Position.Left : Position.Right}
id={id}
<div
className="absolute flex items-center gap-1"
style={{
background: color,
width: isFlow ? 8 : 6,
height: isFlow ? 8 : 6,
borderRadius: isFlow ? 2 : "50%",
border: `1.5px solid ${color}60`,
...style,
[isInput ? "left" : "right"]: -4,
top: style?.top ?? "50%",
transform: "translateY(-50%)",
flexDirection: isInput ? "row" : "row-reverse",
}}
/>
>
<Handle
type={isInput ? "target" : "source"}
position={isInput ? Position.Left : Position.Right}
id={id}
style={{
background: color,
width: isFlow ? 8 : 6,
height: isFlow ? 8 : 6,
borderRadius: isFlow ? 2 : "50%",
border: `1.5px solid ${color}60`,
position: "relative",
left: "auto",
right: "auto",
top: "auto",
}}
/>
{label && (
<span
className="text-[8px] font-mono leading-none whitespace-nowrap pointer-events-none opacity-70"
style={{ color }}
>
{label}
</span>
)}
</div>
);
}