import { useCallback, useEffect, useMemo } from 'react'; import { ReactFlow, Background, Controls, MiniMap, NodeChange, Node, Edge, Connection, MarkerType, } from '@xyflow/react'; import { useDialogueStore } from '../../store/dialogueStore'; import { nodeTypes } from '../nodes/nodeTypes'; import { DialogueNode, ChoiceNode } from '../../types/dialogue'; import { GraphToolbar } from './GraphToolbar'; import styles from './GraphPanel.module.css'; function buildEdges(nodes: DialogueNode[]): Edge[] { const edges: Edge[] = []; for (const node of nodes) { const base = { markerEnd: { type: MarkerType.ArrowClosed, color: '#6c7086' }, style: { stroke: '#6c7086', strokeWidth: 1.5 }, animated: false, }; if (node.type === 'Line' || node.type === 'SetFlag' || node.type === 'CutsceneStart') { if (node.next) { edges.push({ ...base, id: `${node.id}->source`, source: node.id, target: node.next, sourceHandle: 'source', targetHandle: 'target' }); } } else if (node.type === 'Choice') { for (const choice of node.choices) { if (choice.next) { edges.push({ ...base, id: `${node.id}->${choice.id}`, source: node.id, target: choice.next, sourceHandle: choice.id, targetHandle: 'target', label: choice.text.slice(0, 20), labelStyle: { fontSize: 10, fill: '#cdd6f4' }, labelBgStyle: { fill: '#181825' } }); } } } else if (node.type === 'Condition') { if (node.trueNext) { edges.push({ ...base, id: `${node.id}->true`, source: node.id, target: node.trueNext, sourceHandle: 'true', targetHandle: 'target', label: 'TRUE', labelStyle: { fontSize: 10, fill: '#a6e3a1' }, labelBgStyle: { fill: '#181825' }, style: { ...base.style, stroke: '#a6e3a1' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#a6e3a1' } }); } if (node.falseNext) { edges.push({ ...base, id: `${node.id}->false`, source: node.id, target: node.falseNext, sourceHandle: 'false', targetHandle: 'target', label: 'FALSE', labelStyle: { fontSize: 10, fill: '#f38ba8' }, labelBgStyle: { fill: '#181825' }, style: { ...base.style, stroke: '#f38ba8' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#f38ba8' } }); } } } return edges; } export function GraphPanel() { const { file, selectedDialogueId, selectedNodeId, positions, selectNode, setNodePosition, applyAutoLayout, updateNode, } = useDialogueStore(); const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId); const dialoguePositions = positions[selectedDialogueId ?? ''] ?? {}; const rfNodes: Node[] = useMemo(() => { if (!dialogue) return []; return dialogue.nodes.map(node => ({ id: node.id, type: node.type, data: node as unknown as Record, position: dialoguePositions[node.id] ?? { x: 0, y: 0 }, selected: node.id === selectedNodeId, })); }, [dialogue, dialoguePositions, selectedNodeId]); const rfEdges: Edge[] = useMemo(() => { if (!dialogue) return []; return buildEdges(dialogue.nodes); }, [dialogue]); const onNodesChange = useCallback((changes: NodeChange[]) => { if (!selectedDialogueId) return; for (const change of changes) { if (change.type === 'position' && change.position) { setNodePosition(selectedDialogueId, change.id, change.position); } } }, [selectedDialogueId, setNodePosition]); const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { selectNode(node.id); }, [selectNode]); const onPaneClick = useCallback(() => { selectNode(null); }, [selectNode]); const onConnect = useCallback((connection: Connection) => { if (!selectedDialogueId || !connection.source || !connection.target) return; const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId); if (!dialogue) return; const sourceNode = dialogue.nodes.find(n => n.id === connection.source); if (!sourceNode) return; const handle = connection.sourceHandle; if (sourceNode.type === 'Line' || sourceNode.type === 'SetFlag' || sourceNode.type === 'CutsceneStart') { updateNode(selectedDialogueId, sourceNode.id, { next: connection.target } as Partial); } else if (sourceNode.type === 'Condition') { if (handle === 'true') { updateNode(selectedDialogueId, sourceNode.id, { trueNext: connection.target } as Partial); } else if (handle === 'false') { updateNode(selectedDialogueId, sourceNode.id, { falseNext: connection.target } as Partial); } } else if (sourceNode.type === 'Choice') { const choices = (sourceNode as ChoiceNode).choices.map(c => c.id === handle ? { ...c, next: connection.target! } : c ); updateNode(selectedDialogueId, sourceNode.id, { choices } as Partial); } }, [selectedDialogueId, file, updateNode]); // Trigger auto-layout when dialogue is first loaded with no positions useEffect(() => { if (dialogue && dialogue.nodes.length > 0 && !Object.keys(dialoguePositions).length && selectedDialogueId) { applyAutoLayout(selectedDialogueId); } }, [selectedDialogueId]); if (!file) { return (
Load a dialogue JSON file to get started
); } if (!dialogue) { return (
Select a dialogue from the left panel
); } return (
{ switch (n.type) { case 'Line': return '#1e66f5'; case 'Choice': return '#df8e1d'; case 'Condition': return '#8839ef'; case 'SetFlag': return '#179299'; case 'CutsceneStart': return '#4a4a5a'; case 'End': return '#f38ba8'; default: return '#6c7086'; } }} style={{ background: '#181825', border: '1px solid #313244' }} />
); }