"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { ReactFlow, Background, Controls, MiniMap, useNodesState, useEdgesState, addEdge, type Node, type Edge, type Connection, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import { Trash2Icon } from "lucide-react"; import { useField } from "@payloadcms/ui"; import { NarrativeBeatNode } from "./NarrativeBeatNode"; import { ChoiceNode } from "./ChoiceNode"; import { OutcomeNode } from "./OutcomeNode"; import { WaitNode } from "./WaitNode"; import { NumberNode } from "./NumberNode"; import { StringNode } from "./StringNode"; import { RandomNode } from "./RandomNode"; import { BooleanNode } from "./BooleanNode"; import { DateNode } from "./DateNode"; import { GameDataNode } from "./GameDataNode"; import { FlagNode } from "./FlagNode"; import { CompareNode } from "./CompareNode"; import { MathNode } from "./MathNode"; import { FormatNode } from "./FormatNode"; import { LogicNode } from "./LogicNode"; import { NotNode } from "./NotNode"; import { BranchNode } from "./BranchNode"; import { SequenceNode } from "./SequenceNode"; import { NodeEditDialog } from "./NodeEditDialog"; import { NODE_SOCKETS, type SocketType } from "./types"; const nodeTypes = { narrativeBeat: NarrativeBeatNode, choice: ChoiceNode, outcome: OutcomeNode, wait: WaitNode, number: NumberNode, string: StringNode, random: RandomNode, boolean: BooleanNode, date: DateNode, gameData: GameDataNode, flag: FlagNode, compare: CompareNode, math: MathNode, format: FormatNode, logic: LogicNode, not: NotNode, branch: BranchNode, sequence: SequenceNode, }; type FlowData = { nodes: Node[]; edges: Edge[] }; const defaultFlow: FlowData = { nodes: [], edges: [] }; type ContextMenuState = { x: number; y: number; nodeId?: string; edgeId?: string; } | null; export function NarrativeFlowEditor() { const { value, setValue } = useField(); const initialData: FlowData = value && typeof value === "object" && Array.isArray((value as FlowData).nodes) ? (value as FlowData) : defaultFlow; const [nodes, setNodes, onNodesChange] = useNodesState(initialData.nodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialData.edges); const [contextMenu, setContextMenu] = useState(null); const [editingNodeId, setEditingNodeId] = useState(null); const lastSaved = useRef(""); useEffect(() => { const flow = { nodes, edges }; const serialized = JSON.stringify(flow); if (serialized !== lastSaved.current) { lastSaved.current = serialized; setValue(flow); } }, [nodes, edges, setValue]); const onConnect = useCallback( (connection: Connection) => { setEdges((eds) => addEdge({ ...connection, animated: true }, eds)); }, [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); // 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], ); const onNodeClick = useCallback((_e: React.MouseEvent, node: Node) => { setEditingNodeId(node.id); }, []); const onNodeContextMenu = useCallback( (e: React.MouseEvent, node: Node) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id }); }, [], ); const onEdgeContextMenu = useCallback( (e: React.MouseEvent, edge: Edge) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id }); }, [], ); const onPaneContextMenu = useCallback((e: MouseEvent | React.MouseEvent) => { e.preventDefault(); setContextMenu(null); }, []); const deleteSelected = useCallback(() => { if (!contextMenu) return; if (contextMenu.nodeId) { setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId)); setEdges((eds) => eds.filter((e) => e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId), ); } else if (contextMenu.edgeId) { setEdges((eds) => eds.filter((e) => e.id !== contextMenu.edgeId)); } setContextMenu(null); }, [contextMenu, setNodes, setEdges]); const handleNodeSave = useCallback( (nodeId: string, data: Record) => { setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n)), ); }, [setNodes], ); const selectedNode = editingNodeId ? nodes.find((n) => n.id === editingNodeId) ?? null : null; const addNode = useCallback( ( type: | "narrativeBeat" | "choice" | "outcome" | "wait" | "number" | "string" | "random" | "boolean" | "date" | "gameData" | "flag" | "compare" | "math" | "format" | "logic" | "not" | "branch" | "sequence", ) => { const id = crypto.randomUUID(); const pos = { x: Math.random() * 400, y: Math.random() * 300 }; const defaults: Record> = { narrativeBeat: { title: "New beat", text: "" }, choice: { question: "New choice?", options: [] }, outcome: { title: "New outcome", text: "", effects: [] }, wait: { ticks: 10 }, number: { value: 0 }, string: { value: "" }, random: { min: 0, max: 100 }, boolean: { value: false }, date: { value: "" }, gameData: { field: "structure_health" }, flag: { flagName: "" }, compare: { operator: ">" }, math: { operator: "+" }, format: { template: "{0}" }, logic: { operator: "AND" }, not: {}, branch: {}, sequence: {}, }; const newNode: Node = { id, type, position: pos, data: defaults[type] ?? {}, }; setNodes((nds) => [...nds, newNode]); }, [setNodes], ); const buttonClass = "px-2 py-1 text-[10px] font-medium rounded border transition-colors cursor-pointer"; const toolbarButtons = (
Story Data Logic {nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge{edges.length !== 1 ? "s" : ""}
); return (
{toolbarButtons}
{ const colors: Record = { narrativeBeat: "#3b82f6", choice: "#d97706", outcome: "#10b981", wait: "#facc15", number: "#a855f7", string: "#14b8a6", random: "#ec4899", boolean: "#34d399", date: "#06b6d4", gameData: "#6366f1", flag: "#f97316", compare: "#64748b", math: "#8b5cf6", format: "#0ea5e9", logic: "#84cc16", not: "#ef4444", branch: "#f43f5e", sequence: "#d946ef", }; return colors[n.type as string] ?? "#6366f1"; }} /> {contextMenu && ( { if (!open) setContextMenu(null); }} > setContextMenu(null)} className="z-50 min-w-[8rem] overflow-hidden rounded-md border border-zinc-700 bg-zinc-900 p-1 shadow-lg" > Delete{contextMenu.nodeId ? " node" : contextMenu.edgeId ? " connection" : ""} )} { if (!open) setEditingNodeId(null); }} onSave={handleNodeSave} />
); } export default NarrativeFlowEditor;