1
0
Fork 0

feat: typed socket foundation for LiteGraph-style blueprint editor

Phase 1 — Typed Socket Foundation:
- SocketType enum: flow, number, string, boolean, any
- Socket definitions for all 9 existing node types (inputs + outputs)
- TypedHandle component: color-coded circles (flow=white square, number=purple, string=teal, boolean=green, any=indigo)
- NodeHandles helper: auto-renders handles from socket config with proportional spacing
- isValidConnection: rejects type mismatches, any-type sockets accept all connections
- All existing nodes refactored to use NodeHandles instead of manual Handle components
This commit is contained in:
Jason Fraley 2026-07-28 18:11:52 -04:00
parent cfba38ca24
commit 9a7462e5f7
13 changed files with 311 additions and 39 deletions

View file

@ -1,14 +1,15 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { type NodeProps } from "@xyflow/react";
import { CheckCircleIcon } from "lucide-react";
import { NodeHandles } from "./NodeHandles";
export function BooleanNode({ data }: NodeProps) {
const d = data as { value?: boolean; description?: string };
return (
<div className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-28">
<Handle type="target" position={Position.Left} className="!bg-emerald-400" />
<NodeHandles nodeType="boolean" />
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
<CheckCircleIcon className="size-4 text-emerald-400 shrink-0" />
<span className="text-sm font-medium text-emerald-200 flex-1 truncate">
@ -18,7 +19,6 @@ export function BooleanNode({ data }: NodeProps) {
<div className="px-3 py-2 text-xs text-emerald-300/80 line-clamp-2">
{d.description || "Boolean flag"}
</div>
<Handle type="source" position={Position.Right} className="!bg-emerald-400" />
</div>
);
}

View file

@ -1,16 +1,16 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { type NodeProps } from "@xyflow/react";
import { PencilIcon, WaypointsIcon } from "lucide-react";
import { NodeHandles } from "./NodeHandles";
export function ChoiceNode({ data }: NodeProps) {
const d = data as { question?: string; options?: Array<{ id: string; label: string }> };
const options = d.options ?? [];
return (
<div className="bg-amber-950/80 border border-amber-500/50 rounded-lg shadow-md min-w-56">
<Handle type="target" position={Position.Left} className="!bg-amber-400" />
<NodeHandles nodeType="choice" />
<div className="flex items-center gap-2 px-3 py-2 border-b border-amber-500/30">
<WaypointsIcon className="size-4 text-amber-400 shrink-0" />
<span className="text-sm font-medium text-amber-200 flex-1 truncate">
@ -25,16 +25,10 @@ export function ChoiceNode({ data }: NodeProps) {
options.map((opt) => (
<div
key={opt.id}
className="relative flex items-center gap-2 text-xs text-amber-300/80 bg-amber-950/40 rounded px-2 py-1"
className="flex items-center gap-2 text-xs text-amber-300/80 bg-amber-950/40 rounded px-2 py-1"
>
<span className="w-1 h-1 rounded-full bg-amber-400 shrink-0" />
<span className="flex-1 truncate">{opt.label}</span>
<Handle
type="source"
position={Position.Right}
id={opt.id}
className="!bg-amber-400 !size-2 !min-w-0 !min-h-0 !border-0"
/>
</div>
))
)}

View file

@ -1,14 +1,15 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { type NodeProps } from "@xyflow/react";
import { CalendarIcon } from "lucide-react";
import { NodeHandles } from "./NodeHandles";
export function DateNode({ data }: NodeProps) {
const d = data as { value?: string; description?: string };
return (
<div className="bg-cyan-950/80 border border-cyan-500/50 rounded-lg shadow-md min-w-32">
<Handle type="target" position={Position.Left} className="!bg-cyan-400" />
<NodeHandles nodeType="date" />
<div className="flex items-center gap-2 px-3 py-2 border-b border-cyan-500/30">
<CalendarIcon className="size-4 text-cyan-400 shrink-0" />
<span className="text-sm font-medium text-cyan-200 flex-1 truncate">
@ -18,7 +19,6 @@ export function DateNode({ data }: NodeProps) {
<div className="px-3 py-2 text-xs text-cyan-300/80 line-clamp-2">
{d.description || "Date value"}
</div>
<Handle type="source" position={Position.Right} className="!bg-cyan-400" />
</div>
);
}

View file

@ -1,14 +1,15 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { type NodeProps } from "@xyflow/react";
import { BookOpenTextIcon, PencilIcon } from "lucide-react";
import { NodeHandles } from "./NodeHandles";
export function NarrativeBeatNode({ data }: NodeProps) {
const d = data as { title?: string; text?: string };
return (
<div className="bg-blue-950/80 border border-blue-500/50 rounded-lg shadow-md min-w-56">
<Handle type="target" position={Position.Left} className="!bg-blue-400" />
<NodeHandles nodeType="narrativeBeat" />
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-500/30">
<BookOpenTextIcon className="size-4 text-blue-400 shrink-0" />
<span className="text-sm font-medium text-blue-200 flex-1 truncate">
@ -19,7 +20,6 @@ export function NarrativeBeatNode({ data }: NodeProps) {
<div className="px-3 py-2 text-xs text-blue-300/80 line-clamp-3">
{d.text || "No description."}
</div>
<Handle type="source" position={Position.Right} className="!bg-blue-400" />
</div>
);
}

View file

@ -29,6 +29,7 @@ import { RandomNode } from "./RandomNode";
import { BooleanNode } from "./BooleanNode";
import { DateNode } from "./DateNode";
import { NodeEditDialog } from "./NodeEditDialog";
import { NODE_SOCKETS, type SocketType } from "./types";
const nodeTypes = {
narrativeBeat: NarrativeBeatNode,
@ -85,6 +86,29 @@ export function NarrativeFlowEditor() {
[setEdges],
);
const isValidConnection = useCallback(
(connection: Edge | Connection) => {
if (!connection.source || !connection.target || !connection.sourceHandle || !connection.targetHandle) {
return false;
}
const sourceNode = nodes.find((n) => n.id === connection.source);
const targetNode = nodes.find((n) => n.id === connection.target);
if (!sourceNode || !targetNode) return false;
const sourceSockets = NODE_SOCKETS[sourceNode.type as string];
const targetSockets = NODE_SOCKETS[targetNode.type as string];
if (!sourceSockets || !targetSockets) return false;
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;
},
[nodes],
);
const onNodeClick = useCallback((_e: React.MouseEvent, node: Node) => {
setEditingNodeId(node.id);
}, []);
@ -253,6 +277,7 @@ export function NarrativeFlowEditor() {
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
onNodeClick={onNodeClick}
onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu}

View file

@ -0,0 +1,52 @@
"use client";
import { TypedHandle } from "./TypedHandle";
import { NODE_SOCKETS } from "./types";
interface NodeHandlesProps {
nodeType: string;
dynamicOutputs?: Array<{ id: string; label: string }>;
}
export function NodeHandles({ nodeType, 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 maxSlots = Math.max(inputs.length, outputs.length, 1);
return (
<>
{inputs.map((socket, i) => {
const topPercent = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
return (
<TypedHandle
key={socket.id}
id={socket.id}
socketType={socket.type}
label={socket.label}
isInput={true}
style={{ top: `${topPercent}%` }}
/>
);
})}
{outputs.map((socket, i) => {
const topPercent = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
return (
<TypedHandle
key={socket.id}
id={socket.id}
socketType={socket.type}
label={socket.label}
isInput={false}
style={{ top: `${topPercent}%` }}
/>
);
})}
</>
);
}

View file

@ -1,14 +1,15 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { type NodeProps } from "@xyflow/react";
import { HashIcon } from "lucide-react";
import { NodeHandles } from "./NodeHandles";
export function NumberNode({ data }: NodeProps) {
const d = data as { value?: number; description?: string };
return (
<div className="bg-purple-950/80 border border-purple-500/50 rounded-lg shadow-md min-w-32">
<Handle type="target" position={Position.Left} className="!bg-purple-400" />
<NodeHandles nodeType="number" />
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-500/30">
<HashIcon className="size-4 text-purple-400 shrink-0" />
<span className="text-sm font-medium text-purple-200 flex-1 truncate">
@ -18,7 +19,6 @@ export function NumberNode({ data }: NodeProps) {
<div className="px-3 py-2 text-xs text-purple-300/80 line-clamp-2">
{d.description || "Numeric value"}
</div>
<Handle type="source" position={Position.Right} className="!bg-purple-400" />
</div>
);
}

View file

@ -1,7 +1,8 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { type NodeProps } from "@xyflow/react";
import { FlagIcon, PencilIcon } from "lucide-react";
import { NodeHandles } from "./NodeHandles";
export function OutcomeNode({ data }: NodeProps) {
const d = data as {
@ -9,12 +10,11 @@ export function OutcomeNode({ data }: NodeProps) {
text?: string;
effects?: Array<{ type: string; value: string }>;
};
const effects = d.effects ?? [];
return (
<div className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-56">
<Handle type="target" position={Position.Left} className="!bg-emerald-400" />
<NodeHandles nodeType="outcome" />
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
<FlagIcon className="size-4 text-emerald-400 shrink-0" />
<span className="text-sm font-medium text-emerald-200 flex-1 truncate">
@ -33,10 +33,7 @@ export function OutcomeNode({ data }: NodeProps) {
<span className="text-xs text-emerald-400/40 italic block">None</span>
) : (
effects.map((eff, i) => (
<div
key={i}
className="flex items-center gap-1 text-xs text-emerald-300/70"
>
<div key={i} className="flex items-center gap-1 text-xs text-emerald-300/70">
<span className="text-emerald-400/80 font-mono">{eff.type}</span>
<span className="text-emerald-500/40">:</span>
<span className="truncate">{eff.value}</span>

View file

@ -1,14 +1,15 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { type NodeProps } from "@xyflow/react";
import { ShuffleIcon } from "lucide-react";
import { NodeHandles } from "./NodeHandles";
export function RandomNode({ data }: NodeProps) {
const d = data as { min?: number; max?: number; description?: string };
return (
<div className="bg-pink-950/80 border border-pink-500/50 rounded-lg shadow-md min-w-32">
<Handle type="target" position={Position.Left} className="!bg-pink-400" />
<NodeHandles nodeType="random" />
<div className="flex items-center gap-2 px-3 py-2 border-b border-pink-500/30">
<ShuffleIcon className="size-4 text-pink-400 shrink-0" />
<span className="text-sm font-medium text-pink-200 flex-1 truncate">
@ -18,7 +19,6 @@ export function RandomNode({ data }: NodeProps) {
<div className="px-3 py-2 text-xs text-pink-300/80 line-clamp-2">
{d.description || "Random number generator"}
</div>
<Handle type="source" position={Position.Right} className="!bg-pink-400" />
</div>
);
}

View file

@ -1,14 +1,15 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { type NodeProps } from "@xyflow/react";
import { TypeIcon } from "lucide-react";
import { NodeHandles } from "./NodeHandles";
export function StringNode({ data }: NodeProps) {
const d = data as { value?: string; description?: string };
return (
<div className="bg-teal-950/80 border border-teal-500/50 rounded-lg shadow-md min-w-32">
<Handle type="target" position={Position.Left} className="!bg-teal-400" />
<NodeHandles nodeType="string" />
<div className="flex items-center gap-2 px-3 py-2 border-b border-teal-500/30">
<TypeIcon className="size-4 text-teal-400 shrink-0" />
<span className="text-sm font-medium text-teal-200 flex-1 truncate">
@ -18,7 +19,6 @@ export function StringNode({ data }: NodeProps) {
<div className="px-3 py-2 text-xs text-teal-300/80 line-clamp-2">
{d.description || "String value"}
</div>
<Handle type="source" position={Position.Right} className="!bg-teal-400" />
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { Handle, Position, type HandleProps } from "@xyflow/react";
import { SOCKET_COLORS, type SocketType } from "./types";
interface TypedHandleProps {
id: string;
socketType: SocketType;
label?: string;
isInput: boolean;
style?: React.CSSProperties;
}
export function TypedHandle({
id,
socketType,
label,
isInput,
style,
}: TypedHandleProps) {
const color = SOCKET_COLORS[socketType];
const isFlow = socketType === "flow";
return (
<div
className="absolute flex items-center gap-1.5"
style={{
[isInput ? "left" : "right"]: -6,
top: "50%",
transform: "translateY(-50%)",
...style,
}}
>
<Handle
type={isInput ? "target" : "source"}
position={isInput ? Position.Left : Position.Right}
id={id}
style={{
background: color,
width: isFlow ? 12 : 10,
height: isFlow ? 12 : 10,
borderRadius: isFlow ? 2 : "50%",
border: `2px solid ${color}40`,
}}
/>
{label && (
<span
className="text-[9px] font-mono leading-none whitespace-nowrap pointer-events-none"
style={{
color: `${color}cc`,
[isInput ? "paddingLeft" : "paddingRight"]: 2,
}}
>
{label}
</span>
)}
</div>
);
}

View file

@ -1,14 +1,15 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { type NodeProps } from "@xyflow/react";
import { TimerIcon } from "lucide-react";
import { NodeHandles } from "./NodeHandles";
export function WaitNode({ data }: NodeProps) {
const d = data as { ticks?: number; input?: unknown };
return (
<div className="bg-yellow-950/80 border border-yellow-500/50 rounded-lg shadow-md min-w-48">
<Handle type="target" position={Position.Left} className="!bg-yellow-400" />
<NodeHandles nodeType="wait" />
<div className="flex items-center gap-2 px-3 py-2 border-b border-yellow-500/30">
<TimerIcon className="size-4 text-yellow-400 shrink-0" />
<span className="text-sm font-medium text-yellow-200 flex-1 truncate">
@ -16,9 +17,10 @@ export function WaitNode({ data }: NodeProps) {
</span>
</div>
<div className="px-3 py-2 text-xs text-yellow-300/80 line-clamp-2">
{d.input !== undefined ? `Input: ${String(d.input).substring(0, 50)}${String(d.input).length > 50 ? "..." : ""}` : "No input"}
{d.input !== undefined
? `Input: ${String(d.input).substring(0, 50)}${String(d.input).length > 50 ? "..." : ""}`
: "No input"}
</div>
<Handle type="source" position={Position.Right} className="!bg-yellow-400" />
</div>
);
}

View file

@ -0,0 +1,143 @@
export type SocketType = "flow" | "number" | "string" | "boolean" | "any";
export interface SocketDef {
id: string;
type: SocketType;
label: string;
}
export const SOCKET_COLORS: Record<SocketType, string> = {
flow: "#e4e4e7",
number: "#a855f7",
string: "#14b8a6",
boolean: "#22c55e",
any: "#6366f1",
};
export interface NodeSocketConfig {
inputs: SocketDef[];
outputs: SocketDef[];
}
export const NODE_SOCKETS: Record<string, NodeSocketConfig> = {
narrativeBeat: {
inputs: [
{ id: "flow-in", type: "flow", label: "" },
{ id: "text-var", type: "any", label: "text" },
],
outputs: [{ id: "flow-out", type: "flow", label: "" }],
},
choice: {
inputs: [
{ id: "flow-in", type: "flow", label: "" },
{ id: "question-var", type: "string", label: "question" },
],
outputs: [{ id: "flow-out", type: "flow", label: "" }],
},
outcome: {
inputs: [
{ id: "flow-in", type: "flow", label: "" },
{ id: "trigger", type: "boolean", label: "trigger" },
],
outputs: [],
},
wait: {
inputs: [
{ id: "flow-in", type: "flow", label: "" },
{ id: "ticks", type: "number", label: "ticks" },
],
outputs: [{ id: "flow-out", type: "flow", label: "" }],
},
number: {
inputs: [],
outputs: [{ id: "value", type: "number", label: "out" }],
},
string: {
inputs: [],
outputs: [{ id: "value", type: "string", label: "out" }],
},
random: {
inputs: [
{ id: "min", type: "number", label: "min" },
{ id: "max", type: "number", label: "max" },
],
outputs: [{ id: "value", type: "number", label: "out" }],
},
boolean: {
inputs: [],
outputs: [{ id: "value", type: "boolean", label: "out" }],
},
date: {
inputs: [],
outputs: [{ id: "value", type: "string", label: "out" }],
},
gameData: {
inputs: [],
outputs: [
{ id: "number-out", type: "number", label: "num" },
{ id: "string-out", type: "string", label: "str" },
{ id: "boolean-out", type: "boolean", label: "bool" },
],
},
flag: {
inputs: [
{ id: "flow-in", type: "flow", label: "" },
{ id: "set-value", type: "boolean", label: "set" },
],
outputs: [
{ id: "flow-out", type: "flow", label: "" },
{ id: "value", type: "boolean", label: "out" },
],
},
compare: {
inputs: [
{ id: "a", type: "number", label: "A" },
{ id: "b", type: "number", label: "B" },
],
outputs: [{ id: "result", type: "boolean", label: "out" }],
},
math: {
inputs: [
{ id: "a", type: "number", label: "A" },
{ id: "b", type: "number", label: "B" },
],
outputs: [{ id: "result", type: "number", label: "out" }],
},
format: {
inputs: [
{ id: "template", type: "string", label: "tmpl" },
{ id: "arg-0", type: "any", label: "{0}" },
{ id: "arg-1", type: "any", label: "{1}" },
],
outputs: [{ id: "result", type: "string", label: "out" }],
},
logic: {
inputs: [
{ id: "a", type: "boolean", label: "A" },
{ id: "b", type: "boolean", label: "B" },
],
outputs: [{ id: "result", type: "boolean", label: "out" }],
},
not: {
inputs: [{ id: "value", type: "boolean", label: "in" }],
outputs: [{ id: "result", type: "boolean", label: "out" }],
},
branch: {
inputs: [
{ id: "flow-in", type: "flow", label: "" },
{ id: "condition", type: "boolean", label: "cond" },
],
outputs: [
{ id: "flow-true", type: "flow", label: "true" },
{ id: "flow-false", type: "flow", label: "false" },
],
},
sequence: {
inputs: [{ id: "flow-in", type: "flow", label: "" }],
outputs: [
{ id: "flow-1", type: "flow", label: "1" },
{ id: "flow-2", type: "flow", label: "2" },
{ id: "flow-3", type: "flow", label: "3" },
],
},
};