"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 { 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, }; 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); 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); }, []); 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", ) => { const id = crypto.randomUUID(); const pos = { x: Math.random() * 400, y: Math.random() * 300 }; const newNode: Node = { id, type, position: pos, data: type === "choice" ? { question: "New choice?", options: [] } : type === "outcome" ? { title: "New outcome", text: "", effects: [] } : type === "wait" ? { ticks: 10 } : type === "number" ? { value: 0 } : type === "string" ? { value: "" } : type === "random" ? { min: 0, max: 100 } : type === "boolean" ? { value: false } : type === "date" ? { value: "" } : { title: "New beat", text: "" }, }; setNodes((nds) => [...nds, newNode]); }, [setNodes], ); const buttonClass = "px-3 py-1.5 text-xs font-medium rounded-md border transition-colors cursor-pointer"; const toolbarButtons = (
{nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge {edges.length !== 1 ? "s" : ""}
); return (
{toolbarButtons}
{ switch (n.type) { case "choice": return "#d97706"; // amber case "outcome": return "#10b981"; // emerald case "narrativeBeat": return "#3b82f6"; // blue case "wait": return "#facc15"; // yellow case "number": return "#a855f7"; // purple case "string": return "#14b8a6"; // teal case "random": return "#ec4899"; // pink case "boolean": return "#34d399"; // light green case "date": return "#06b6d4"; // cyan default: return "#6366f1"; // indigo } }} /> {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;