186 lines
6.7 KiB
TypeScript
186 lines
6.7 KiB
TypeScript
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<string, unknown>,
|
|
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<DialogueNode>);
|
|
} else if (sourceNode.type === 'Condition') {
|
|
if (handle === 'true') {
|
|
updateNode(selectedDialogueId, sourceNode.id, { trueNext: connection.target } as Partial<DialogueNode>);
|
|
} else if (handle === 'false') {
|
|
updateNode(selectedDialogueId, sourceNode.id, { falseNext: connection.target } as Partial<DialogueNode>);
|
|
}
|
|
} 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<DialogueNode>);
|
|
}
|
|
}, [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 (
|
|
<div className={styles.empty}>
|
|
<div className={styles.emptyMsg}>Load a dialogue JSON file to get started</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!dialogue) {
|
|
return (
|
|
<div className={styles.empty}>
|
|
<div className={styles.emptyMsg}>Select a dialogue from the left panel</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
<GraphToolbar />
|
|
<div className={styles.flow}>
|
|
<ReactFlow
|
|
key={selectedDialogueId}
|
|
nodes={rfNodes}
|
|
edges={rfEdges}
|
|
nodeTypes={nodeTypes}
|
|
onNodesChange={onNodesChange}
|
|
onConnect={onConnect}
|
|
onNodeClick={onNodeClick}
|
|
onPaneClick={onPaneClick}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.2 }}
|
|
deleteKeyCode={null}
|
|
proOptions={{ hideAttribution: true }}
|
|
>
|
|
<Background color="#313244" gap={20} />
|
|
<Controls />
|
|
<MiniMap
|
|
nodeColor={(n) => {
|
|
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' }}
|
|
/>
|
|
</ReactFlow>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|