Working on dialog editor, on dialogs, on phone

This commit is contained in:
Vladislav Khorev committed 2026-06-05 21:17:51 +03:00
1 parent 0e27521bea
commit 8f7797789d
63 files changed
+9502 -1459

No files matched your search

@@ -0,0 +1,185 @@
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>
);
}