@magicborn/dialogue-forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +233 -0
- package/bin/dialogue-forge.js +78 -0
- package/demo/app/layout.tsx +36 -0
- package/demo/app/page.tsx +440 -0
- package/demo/components/ThemeSwitcher.tsx +611 -0
- package/demo/next.config.mjs +7 -0
- package/demo/package.json +29 -0
- package/demo/postcss.config.mjs +7 -0
- package/demo/public/logo.svg +1 -0
- package/demo/styles/globals.css +19 -0
- package/demo/tailwind.config.ts +90 -0
- package/demo/tsconfig.json +42 -0
- package/dist/components/ChoiceEdgeV2.d.ts +3 -0
- package/dist/components/ChoiceEdgeV2.js +103 -0
- package/dist/components/CodeBlock.d.ts +8 -0
- package/dist/components/CodeBlock.js +24 -0
- package/dist/components/ConditionAutocomplete.d.ts +14 -0
- package/dist/components/ConditionAutocomplete.js +284 -0
- package/dist/components/ConditionalNodeV2.d.ts +16 -0
- package/dist/components/ConditionalNodeV2.js +147 -0
- package/dist/components/DialogueEditorV2.d.ts +22 -0
- package/dist/components/DialogueEditorV2.js +1170 -0
- package/dist/components/EdgeIcon.d.ts +8 -0
- package/dist/components/EdgeIcon.js +13 -0
- package/dist/components/ExampleLoader.d.ts +11 -0
- package/dist/components/ExampleLoader.js +52 -0
- package/dist/components/ExampleLoaderButton.d.ts +15 -0
- package/dist/components/ExampleLoaderButton.js +102 -0
- package/dist/components/FlagManager.d.ts +11 -0
- package/dist/components/FlagManager.js +282 -0
- package/dist/components/FlagSelector.d.ts +11 -0
- package/dist/components/FlagSelector.js +235 -0
- package/dist/components/GuidePanel.d.ts +7 -0
- package/dist/components/GuidePanel.js +1176 -0
- package/dist/components/Minimap.d.ts +16 -0
- package/dist/components/Minimap.js +93 -0
- package/dist/components/NPCEdgeV2.d.ts +3 -0
- package/dist/components/NPCEdgeV2.js +104 -0
- package/dist/components/NPCNodeV2.d.ts +26 -0
- package/dist/components/NPCNodeV2.js +86 -0
- package/dist/components/NodeEditor.d.ts +18 -0
- package/dist/components/NodeEditor.js +1025 -0
- package/dist/components/PlayView.d.ts +12 -0
- package/dist/components/PlayView.js +307 -0
- package/dist/components/PlayerNodeV2.d.ts +16 -0
- package/dist/components/PlayerNodeV2.js +139 -0
- package/dist/components/ReactFlowPOC.d.ts +61 -0
- package/dist/components/ReactFlowPOC.js +312 -0
- package/dist/components/ScenePlayer.d.ts +18 -0
- package/dist/components/ScenePlayer.js +196 -0
- package/dist/components/YarnView.d.ts +9 -0
- package/dist/components/YarnView.js +45 -0
- package/dist/components/ZoomControls.d.ts +11 -0
- package/dist/components/ZoomControls.js +34 -0
- package/dist/esm/components/ChoiceEdgeV2.d.ts +3 -0
- package/dist/esm/components/ChoiceEdgeV2.js +67 -0
- package/dist/esm/components/CodeBlock.d.ts +8 -0
- package/dist/esm/components/CodeBlock.js +18 -0
- package/dist/esm/components/ConditionAutocomplete.d.ts +14 -0
- package/dist/esm/components/ConditionAutocomplete.js +248 -0
- package/dist/esm/components/ConditionalNodeV2.d.ts +16 -0
- package/dist/esm/components/ConditionalNodeV2.js +111 -0
- package/dist/esm/components/DialogueEditorV2.d.ts +22 -0
- package/dist/esm/components/DialogueEditorV2.js +1134 -0
- package/dist/esm/components/EdgeIcon.d.ts +8 -0
- package/dist/esm/components/EdgeIcon.js +7 -0
- package/dist/esm/components/ExampleLoader.d.ts +11 -0
- package/dist/esm/components/ExampleLoader.js +46 -0
- package/dist/esm/components/ExampleLoaderButton.d.ts +15 -0
- package/dist/esm/components/ExampleLoaderButton.js +66 -0
- package/dist/esm/components/FlagManager.d.ts +11 -0
- package/dist/esm/components/FlagManager.js +246 -0
- package/dist/esm/components/FlagSelector.d.ts +11 -0
- package/dist/esm/components/FlagSelector.js +199 -0
- package/dist/esm/components/GuidePanel.d.ts +7 -0
- package/dist/esm/components/GuidePanel.js +1140 -0
- package/dist/esm/components/Minimap.d.ts +16 -0
- package/dist/esm/components/Minimap.js +57 -0
- package/dist/esm/components/NPCEdgeV2.d.ts +3 -0
- package/dist/esm/components/NPCEdgeV2.js +68 -0
- package/dist/esm/components/NPCNodeV2.d.ts +26 -0
- package/dist/esm/components/NPCNodeV2.js +80 -0
- package/dist/esm/components/NodeEditor.d.ts +18 -0
- package/dist/esm/components/NodeEditor.js +989 -0
- package/dist/esm/components/PlayView.d.ts +12 -0
- package/dist/esm/components/PlayView.js +271 -0
- package/dist/esm/components/PlayerNodeV2.d.ts +16 -0
- package/dist/esm/components/PlayerNodeV2.js +103 -0
- package/dist/esm/components/ReactFlowPOC.d.ts +61 -0
- package/dist/esm/components/ReactFlowPOC.js +275 -0
- package/dist/esm/components/ScenePlayer.d.ts +18 -0
- package/dist/esm/components/ScenePlayer.js +160 -0
- package/dist/esm/components/YarnView.d.ts +9 -0
- package/dist/esm/components/YarnView.js +39 -0
- package/dist/esm/components/ZoomControls.d.ts +11 -0
- package/dist/esm/components/ZoomControls.js +28 -0
- package/dist/esm/examples/example-loader.d.ts +29 -0
- package/dist/esm/examples/example-loader.js +103 -0
- package/dist/esm/examples/examples-registry.d.ts +38 -0
- package/dist/esm/examples/examples-registry.js +153 -0
- package/dist/esm/examples/index.d.ts +26 -0
- package/dist/esm/examples/index.js +50 -0
- package/dist/esm/examples/legacy-examples.d.ts +9 -0
- package/dist/esm/examples/legacy-examples.js +814 -0
- package/dist/esm/examples/yarn-examples.d.ts +35 -0
- package/dist/esm/examples/yarn-examples.js +181 -0
- package/dist/esm/index.d.ts +21 -0
- package/dist/esm/index.js +26 -0
- package/dist/esm/lib/flag-manager.d.ts +21 -0
- package/dist/esm/lib/flag-manager.js +93 -0
- package/dist/esm/lib/yarn-converter/__tests__/round-trip.test.d.ts +1 -0
- package/dist/esm/lib/yarn-converter/__tests__/round-trip.test.js +169 -0
- package/dist/esm/lib/yarn-converter.d.ts +17 -0
- package/dist/esm/lib/yarn-converter.js +521 -0
- package/dist/esm/lib/yarn-runner/__tests__/condition-evaluator.test.d.ts +1 -0
- package/dist/esm/lib/yarn-runner/__tests__/condition-evaluator.test.js +171 -0
- package/dist/esm/lib/yarn-runner/__tests__/node-processor.test.d.ts +1 -0
- package/dist/esm/lib/yarn-runner/__tests__/node-processor.test.js +237 -0
- package/dist/esm/lib/yarn-runner/__tests__/variable-manager.test.d.ts +1 -0
- package/dist/esm/lib/yarn-runner/__tests__/variable-manager.test.js +106 -0
- package/dist/esm/lib/yarn-runner/condition-evaluator.d.ts +12 -0
- package/dist/esm/lib/yarn-runner/condition-evaluator.js +56 -0
- package/dist/esm/lib/yarn-runner/index.d.ts +12 -0
- package/dist/esm/lib/yarn-runner/index.js +11 -0
- package/dist/esm/lib/yarn-runner/node-processor.d.ts +18 -0
- package/dist/esm/lib/yarn-runner/node-processor.js +129 -0
- package/dist/esm/lib/yarn-runner/variable-manager.d.ts +51 -0
- package/dist/esm/lib/yarn-runner/variable-manager.js +120 -0
- package/dist/esm/lib/yarn-runner/variable-operations.d.ts +16 -0
- package/dist/esm/lib/yarn-runner/variable-operations.js +88 -0
- package/dist/esm/types/conditionals.d.ts +29 -0
- package/dist/esm/types/conditionals.js +1 -0
- package/dist/esm/types/constants.d.ts +59 -0
- package/dist/esm/types/constants.js +55 -0
- package/dist/esm/types/flags.d.ts +49 -0
- package/dist/esm/types/flags.js +49 -0
- package/dist/esm/types/game-state.d.ts +62 -0
- package/dist/esm/types/game-state.js +6 -0
- package/dist/esm/types/index.d.ts +77 -0
- package/dist/esm/types/index.js +1 -0
- package/dist/esm/utils/constants.d.ts +5 -0
- package/dist/esm/utils/constants.js +5 -0
- package/dist/esm/utils/feature-flags.d.ts +11 -0
- package/dist/esm/utils/feature-flags.js +11 -0
- package/dist/esm/utils/game-state-flattener.d.ts +41 -0
- package/dist/esm/utils/game-state-flattener.js +135 -0
- package/dist/esm/utils/layout/collision.d.ts +27 -0
- package/dist/esm/utils/layout/collision.js +74 -0
- package/dist/esm/utils/layout/index.d.ts +82 -0
- package/dist/esm/utils/layout/index.js +98 -0
- package/dist/esm/utils/layout/registry.d.ts +91 -0
- package/dist/esm/utils/layout/registry.js +148 -0
- package/dist/esm/utils/layout/strategies/dagre.d.ts +19 -0
- package/dist/esm/utils/layout/strategies/dagre.js +182 -0
- package/dist/esm/utils/layout/strategies/force.d.ts +21 -0
- package/dist/esm/utils/layout/strategies/force.js +178 -0
- package/dist/esm/utils/layout/strategies/grid.d.ts +17 -0
- package/dist/esm/utils/layout/strategies/grid.js +91 -0
- package/dist/esm/utils/layout/strategies/index.d.ts +8 -0
- package/dist/esm/utils/layout/strategies/index.js +8 -0
- package/dist/esm/utils/layout/types.d.ts +100 -0
- package/dist/esm/utils/layout/types.js +7 -0
- package/dist/esm/utils/layout.d.ts +9 -0
- package/dist/esm/utils/layout.js +17 -0
- package/dist/esm/utils/node-helpers.d.ts +7 -0
- package/dist/esm/utils/node-helpers.js +94 -0
- package/dist/esm/utils/reactflow-converter.d.ts +42 -0
- package/dist/esm/utils/reactflow-converter.js +217 -0
- package/dist/examples/example-loader.d.ts +29 -0
- package/dist/examples/example-loader.js +109 -0
- package/dist/examples/examples-registry.d.ts +38 -0
- package/dist/examples/examples-registry.js +160 -0
- package/dist/examples/index.d.ts +26 -0
- package/dist/examples/index.js +63 -0
- package/dist/examples/legacy-examples.d.ts +9 -0
- package/dist/examples/legacy-examples.js +817 -0
- package/dist/examples/yarn-examples.d.ts +35 -0
- package/dist/examples/yarn-examples.js +189 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +66 -0
- package/dist/lib/flag-manager.d.ts +21 -0
- package/dist/lib/flag-manager.js +99 -0
- package/dist/lib/yarn-converter/__tests__/round-trip.test.d.ts +1 -0
- package/dist/lib/yarn-converter/__tests__/round-trip.test.js +171 -0
- package/dist/lib/yarn-converter.d.ts +17 -0
- package/dist/lib/yarn-converter.js +525 -0
- package/dist/lib/yarn-runner/__tests__/condition-evaluator.test.d.ts +1 -0
- package/dist/lib/yarn-runner/__tests__/condition-evaluator.test.js +173 -0
- package/dist/lib/yarn-runner/__tests__/node-processor.test.d.ts +1 -0
- package/dist/lib/yarn-runner/__tests__/node-processor.test.js +239 -0
- package/dist/lib/yarn-runner/__tests__/variable-manager.test.d.ts +1 -0
- package/dist/lib/yarn-runner/__tests__/variable-manager.test.js +108 -0
- package/dist/lib/yarn-runner/condition-evaluator.d.ts +12 -0
- package/dist/lib/yarn-runner/condition-evaluator.js +60 -0
- package/dist/lib/yarn-runner/index.d.ts +12 -0
- package/dist/lib/yarn-runner/index.js +21 -0
- package/dist/lib/yarn-runner/node-processor.d.ts +18 -0
- package/dist/lib/yarn-runner/node-processor.js +133 -0
- package/dist/lib/yarn-runner/variable-manager.d.ts +51 -0
- package/dist/lib/yarn-runner/variable-manager.js +124 -0
- package/dist/lib/yarn-runner/variable-operations.d.ts +16 -0
- package/dist/lib/yarn-runner/variable-operations.js +92 -0
- package/dist/types/conditionals.d.ts +29 -0
- package/dist/types/conditionals.js +2 -0
- package/dist/types/constants.d.ts +59 -0
- package/dist/types/constants.js +58 -0
- package/dist/types/flags.d.ts +49 -0
- package/dist/types/flags.js +52 -0
- package/dist/types/game-state.d.ts +62 -0
- package/dist/types/game-state.js +7 -0
- package/dist/types/index.d.ts +77 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/constants.d.ts +5 -0
- package/dist/utils/constants.js +8 -0
- package/dist/utils/feature-flags.d.ts +11 -0
- package/dist/utils/feature-flags.js +14 -0
- package/dist/utils/game-state-flattener.d.ts +41 -0
- package/dist/utils/game-state-flattener.js +140 -0
- package/dist/utils/layout/collision.d.ts +27 -0
- package/dist/utils/layout/collision.js +77 -0
- package/dist/utils/layout/index.d.ts +82 -0
- package/dist/utils/layout/index.js +109 -0
- package/dist/utils/layout/registry.d.ts +91 -0
- package/dist/utils/layout/registry.js +151 -0
- package/dist/utils/layout/strategies/dagre.d.ts +19 -0
- package/dist/utils/layout/strategies/dagre.js +189 -0
- package/dist/utils/layout/strategies/force.d.ts +21 -0
- package/dist/utils/layout/strategies/force.js +182 -0
- package/dist/utils/layout/strategies/grid.d.ts +17 -0
- package/dist/utils/layout/strategies/grid.js +95 -0
- package/dist/utils/layout/strategies/index.d.ts +8 -0
- package/dist/utils/layout/strategies/index.js +14 -0
- package/dist/utils/layout/types.d.ts +100 -0
- package/dist/utils/layout/types.js +8 -0
- package/dist/utils/layout.d.ts +9 -0
- package/dist/utils/layout.js +25 -0
- package/dist/utils/node-helpers.d.ts +7 -0
- package/dist/utils/node-helpers.js +101 -0
- package/dist/utils/reactflow-converter.d.ts +42 -0
- package/dist/utils/reactflow-converter.js +223 -0
- package/package.json +70 -0
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialogue Editor V2 - React Flow Implementation
|
|
3
|
+
*
|
|
4
|
+
* This is the new version using React Flow for graph rendering.
|
|
5
|
+
* See V2_MIGRATION_PLAN.md for implementation details.
|
|
6
|
+
*/
|
|
7
|
+
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
|
8
|
+
import ReactFlow, { ReactFlowProvider, Background, MiniMap, addEdge, applyNodeChanges, applyEdgeChanges, useReactFlow, Panel, ConnectionLineType, BackgroundVariant, } from 'reactflow';
|
|
9
|
+
import { Edit3, Plus, Trash2, Layout, ArrowDown, ArrowRight, Magnet, Sparkles, Undo2, Flag, Home, BookOpen, Settings, Grid3x3 } from 'lucide-react';
|
|
10
|
+
import { ExampleLoaderButton } from './ExampleLoaderButton';
|
|
11
|
+
import { ENABLE_DEBUG_TOOLS } from '../utils/feature-flags';
|
|
12
|
+
import 'reactflow/dist/style.css';
|
|
13
|
+
import { exportToYarn, importFromYarn } from '../lib/yarn-converter';
|
|
14
|
+
import { convertDialogueTreeToReactFlow } from '../utils/reactflow-converter';
|
|
15
|
+
import { createNode, deleteNodeFromTree, addChoiceToNode, removeChoiceFromNode, updateChoiceInNode } from '../utils/node-helpers';
|
|
16
|
+
import { applyLayout, listLayouts, resolveNodeCollisions } from '../utils/layout';
|
|
17
|
+
import { NodeEditor } from './NodeEditor';
|
|
18
|
+
import { YarnView } from './YarnView';
|
|
19
|
+
import { PlayView } from './PlayView';
|
|
20
|
+
import { NPCNodeV2 } from './NPCNodeV2';
|
|
21
|
+
import { PlayerNodeV2 } from './PlayerNodeV2';
|
|
22
|
+
import { ConditionalNodeV2 } from './ConditionalNodeV2';
|
|
23
|
+
import { ChoiceEdgeV2 } from './ChoiceEdgeV2';
|
|
24
|
+
import { NPCEdgeV2 } from './NPCEdgeV2';
|
|
25
|
+
// Define node and edge types outside component for stability
|
|
26
|
+
const nodeTypes = {
|
|
27
|
+
npc: NPCNodeV2,
|
|
28
|
+
player: PlayerNodeV2,
|
|
29
|
+
conditional: ConditionalNodeV2,
|
|
30
|
+
};
|
|
31
|
+
const edgeTypes = {
|
|
32
|
+
choice: ChoiceEdgeV2,
|
|
33
|
+
default: NPCEdgeV2, // Use custom component for NPC edges instead of React Flow default
|
|
34
|
+
};
|
|
35
|
+
function DialogueEditorV2Internal({ dialogue, onChange, onExportYarn, onExportJSON, className = '', showTitleEditor = true, flagSchema, initialViewMode = 'graph', viewMode: controlledViewMode, onViewModeChange, layoutStrategy: propLayoutStrategy = 'dagre', // Accept from parent
|
|
36
|
+
onLayoutStrategyChange, onOpenFlagManager, onOpenGuide, onLoadExampleDialogue, onLoadExampleFlags,
|
|
37
|
+
// Event hooks
|
|
38
|
+
onNodeAdd, onNodeDelete, onNodeUpdate, onConnect: onConnectHook, onDisconnect, onNodeSelect, onNodeDoubleClick: onNodeDoubleClickHook, }) {
|
|
39
|
+
// Use controlled viewMode if provided, otherwise use internal state
|
|
40
|
+
const [internalViewMode, setInternalViewMode] = useState(initialViewMode);
|
|
41
|
+
const viewMode = controlledViewMode ?? internalViewMode;
|
|
42
|
+
const setViewMode = (mode) => {
|
|
43
|
+
if (controlledViewMode === undefined) {
|
|
44
|
+
setInternalViewMode(mode);
|
|
45
|
+
}
|
|
46
|
+
onViewModeChange?.(mode);
|
|
47
|
+
};
|
|
48
|
+
const [layoutDirection, setLayoutDirection] = useState('TB');
|
|
49
|
+
const layoutStrategy = propLayoutStrategy; // Use prop instead of state
|
|
50
|
+
const [autoOrganize, setAutoOrganize] = useState(false); // Auto-layout on changes
|
|
51
|
+
const [showPathHighlight, setShowPathHighlight] = useState(true); // Toggle path highlighting
|
|
52
|
+
const [showBackEdges, setShowBackEdges] = useState(true); // Toggle back-edge styling
|
|
53
|
+
const [showLayoutMenu, setShowLayoutMenu] = useState(false);
|
|
54
|
+
const lastWheelClickRef = useRef(0);
|
|
55
|
+
// Memoize nodeTypes and edgeTypes to prevent React Flow warnings
|
|
56
|
+
const memoizedNodeTypes = useMemo(() => nodeTypes, []);
|
|
57
|
+
const memoizedEdgeTypes = useMemo(() => edgeTypes, []);
|
|
58
|
+
const [selectedNodeId, setSelectedNodeId] = useState(null);
|
|
59
|
+
const [contextMenu, setContextMenu] = useState(null);
|
|
60
|
+
const [nodeContextMenu, setNodeContextMenu] = useState(null);
|
|
61
|
+
const [edgeContextMenu, setEdgeContextMenu] = useState(null);
|
|
62
|
+
const [edgeDropMenu, setEdgeDropMenu] = useState(null);
|
|
63
|
+
const reactFlowInstance = useReactFlow();
|
|
64
|
+
const connectingRef = useRef(null);
|
|
65
|
+
// Convert DialogueTree to React Flow format
|
|
66
|
+
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => dialogue ? convertDialogueTreeToReactFlow(dialogue, layoutDirection) : { nodes: [], edges: [] }, [dialogue, layoutDirection]);
|
|
67
|
+
const [nodes, setNodes] = useState(initialNodes);
|
|
68
|
+
const [edges, setEdges] = useState(initialEdges);
|
|
69
|
+
// Find all edges that lead to the selected node by tracing FORWARD from start
|
|
70
|
+
// This avoids including back-edges and only shows the actual forward path
|
|
71
|
+
const { edgesToSelectedNode, nodeDepths } = useMemo(() => {
|
|
72
|
+
if (!selectedNodeId || !dialogue || !dialogue.startNodeId) {
|
|
73
|
+
return { edgesToSelectedNode: new Set(), nodeDepths: new Map() };
|
|
74
|
+
}
|
|
75
|
+
// Step 1: Find all forward paths from start that reach the selected node
|
|
76
|
+
// Use DFS to trace forward, tracking the path
|
|
77
|
+
const nodesOnPath = new Set();
|
|
78
|
+
const edgesOnPath = new Set();
|
|
79
|
+
const nodeDepthMap = new Map();
|
|
80
|
+
// DFS that returns true if this path leads to the selected node
|
|
81
|
+
const findPathToTarget = (currentNodeId, visitedInPath, depth) => {
|
|
82
|
+
// Found the target!
|
|
83
|
+
if (currentNodeId === selectedNodeId) {
|
|
84
|
+
nodesOnPath.add(currentNodeId);
|
|
85
|
+
nodeDepthMap.set(currentNodeId, depth);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
// Avoid cycles in THIS path (back-edges)
|
|
89
|
+
if (visitedInPath.has(currentNodeId)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const node = dialogue.nodes[currentNodeId];
|
|
93
|
+
if (!node)
|
|
94
|
+
return false;
|
|
95
|
+
visitedInPath.add(currentNodeId);
|
|
96
|
+
let foundPath = false;
|
|
97
|
+
// Check NPC nextNodeId
|
|
98
|
+
if (node.nextNodeId && dialogue.nodes[node.nextNodeId]) {
|
|
99
|
+
if (findPathToTarget(node.nextNodeId, new Set(visitedInPath), depth + 1)) {
|
|
100
|
+
foundPath = true;
|
|
101
|
+
edgesOnPath.add(`${currentNodeId}-next`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Check player choices
|
|
105
|
+
if (node.choices) {
|
|
106
|
+
node.choices.forEach((choice, idx) => {
|
|
107
|
+
if (choice.nextNodeId && dialogue.nodes[choice.nextNodeId]) {
|
|
108
|
+
if (findPathToTarget(choice.nextNodeId, new Set(visitedInPath), depth + 1)) {
|
|
109
|
+
foundPath = true;
|
|
110
|
+
edgesOnPath.add(`${currentNodeId}-choice-${idx}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// Check conditional blocks
|
|
116
|
+
if (node.conditionalBlocks) {
|
|
117
|
+
node.conditionalBlocks.forEach((block, idx) => {
|
|
118
|
+
if (block.nextNodeId && dialogue.nodes[block.nextNodeId]) {
|
|
119
|
+
if (findPathToTarget(block.nextNodeId, new Set(visitedInPath), depth + 1)) {
|
|
120
|
+
foundPath = true;
|
|
121
|
+
edgesOnPath.add(`${currentNodeId}-block-${idx}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
// If any path from this node leads to target, include this node
|
|
127
|
+
if (foundPath) {
|
|
128
|
+
nodesOnPath.add(currentNodeId);
|
|
129
|
+
// Keep the minimum depth (closest to start)
|
|
130
|
+
if (!nodeDepthMap.has(currentNodeId) || nodeDepthMap.get(currentNodeId) > depth) {
|
|
131
|
+
nodeDepthMap.set(currentNodeId, depth);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return foundPath;
|
|
135
|
+
};
|
|
136
|
+
// Start the search from the dialogue's start node
|
|
137
|
+
findPathToTarget(dialogue.startNodeId, new Set(), 0);
|
|
138
|
+
return { edgesToSelectedNode: edgesOnPath, nodeDepths: nodeDepthMap };
|
|
139
|
+
}, [selectedNodeId, dialogue]);
|
|
140
|
+
// Update nodes/edges when dialogue changes externally
|
|
141
|
+
React.useEffect(() => {
|
|
142
|
+
if (dialogue) {
|
|
143
|
+
const { nodes: newNodes, edges: newEdges } = convertDialogueTreeToReactFlow(dialogue, layoutDirection);
|
|
144
|
+
setNodes(newNodes);
|
|
145
|
+
setEdges(newEdges);
|
|
146
|
+
}
|
|
147
|
+
}, [dialogue]);
|
|
148
|
+
// Calculate end nodes (nodes with no outgoing connections)
|
|
149
|
+
const endNodeIds = useMemo(() => {
|
|
150
|
+
if (!dialogue)
|
|
151
|
+
return new Set();
|
|
152
|
+
const ends = new Set();
|
|
153
|
+
Object.values(dialogue.nodes).forEach(node => {
|
|
154
|
+
const hasNextNode = !!node.nextNodeId;
|
|
155
|
+
const hasChoiceConnections = node.choices?.some(c => c.nextNodeId) || false;
|
|
156
|
+
const hasBlockConnections = node.conditionalBlocks?.some(b => b.nextNodeId) || false;
|
|
157
|
+
if (!hasNextNode && !hasChoiceConnections && !hasBlockConnections) {
|
|
158
|
+
ends.add(node.id);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
return ends;
|
|
162
|
+
}, [dialogue]);
|
|
163
|
+
// Add flagSchema, dim state, and layout direction to node data
|
|
164
|
+
const nodesWithFlags = useMemo(() => {
|
|
165
|
+
const hasSelection = selectedNodeId !== null && showPathHighlight;
|
|
166
|
+
const startNodeId = dialogue?.startNodeId;
|
|
167
|
+
return nodes.map(node => {
|
|
168
|
+
const isInPath = showPathHighlight && nodeDepths.has(node.id);
|
|
169
|
+
const isSelected = node.id === selectedNodeId;
|
|
170
|
+
// Dim nodes that aren't in the path when something is selected (only if path highlight is on)
|
|
171
|
+
const isDimmed = hasSelection && !isInPath && !isSelected;
|
|
172
|
+
const isStartNode = node.id === startNodeId;
|
|
173
|
+
const isEndNode = endNodeIds.has(node.id);
|
|
174
|
+
return {
|
|
175
|
+
...node,
|
|
176
|
+
data: {
|
|
177
|
+
...node.data,
|
|
178
|
+
flagSchema,
|
|
179
|
+
isDimmed,
|
|
180
|
+
isInPath,
|
|
181
|
+
layoutDirection,
|
|
182
|
+
isStartNode,
|
|
183
|
+
isEndNode,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
}, [nodes, flagSchema, nodeDepths, selectedNodeId, layoutDirection, showPathHighlight, dialogue, endNodeIds]);
|
|
188
|
+
if (!dialogue) {
|
|
189
|
+
return (React.createElement("div", { className: `dialogue-editor-v2-empty ${className}` },
|
|
190
|
+
React.createElement("p", null, "No dialogue loaded. Please provide a dialogue tree.")));
|
|
191
|
+
}
|
|
192
|
+
// Get selected node - use useMemo to ensure it updates when dialogue changes
|
|
193
|
+
const selectedNode = useMemo(() => {
|
|
194
|
+
if (!selectedNodeId || !dialogue)
|
|
195
|
+
return null;
|
|
196
|
+
const node = dialogue.nodes[selectedNodeId];
|
|
197
|
+
if (!node)
|
|
198
|
+
return null;
|
|
199
|
+
// Return a fresh copy to ensure React detects changes
|
|
200
|
+
return {
|
|
201
|
+
...node,
|
|
202
|
+
choices: node.choices ? node.choices.map(c => ({ ...c })) : undefined,
|
|
203
|
+
setFlags: node.setFlags ? [...node.setFlags] : undefined,
|
|
204
|
+
conditionalBlocks: node.conditionalBlocks ? node.conditionalBlocks.map(b => ({
|
|
205
|
+
...b,
|
|
206
|
+
condition: b.condition ? [...b.condition] : undefined,
|
|
207
|
+
})) : undefined,
|
|
208
|
+
};
|
|
209
|
+
}, [selectedNodeId, dialogue]);
|
|
210
|
+
// Handle node deletion (multi-delete support)
|
|
211
|
+
const onNodesDelete = useCallback((deleted) => {
|
|
212
|
+
let updatedNodes = { ...dialogue.nodes };
|
|
213
|
+
let shouldClearSelection = false;
|
|
214
|
+
deleted.forEach(node => {
|
|
215
|
+
const dialogueNode = dialogue.nodes[node.id];
|
|
216
|
+
delete updatedNodes[node.id];
|
|
217
|
+
if (selectedNodeId === node.id) {
|
|
218
|
+
shouldClearSelection = true;
|
|
219
|
+
}
|
|
220
|
+
// Call onNodeDelete hook
|
|
221
|
+
onNodeDelete?.(node.id);
|
|
222
|
+
});
|
|
223
|
+
let newDialogue = { ...dialogue, nodes: updatedNodes };
|
|
224
|
+
// Auto-organize if enabled
|
|
225
|
+
if (autoOrganize) {
|
|
226
|
+
const result = applyLayout(newDialogue, layoutStrategy, { direction: layoutDirection });
|
|
227
|
+
newDialogue = result.dialogue;
|
|
228
|
+
setTimeout(() => {
|
|
229
|
+
if (reactFlowInstance) {
|
|
230
|
+
reactFlowInstance.fitView({ padding: 0.2, duration: 300 });
|
|
231
|
+
}
|
|
232
|
+
}, 50);
|
|
233
|
+
}
|
|
234
|
+
onChange(newDialogue);
|
|
235
|
+
if (shouldClearSelection) {
|
|
236
|
+
setSelectedNodeId(null);
|
|
237
|
+
}
|
|
238
|
+
}, [dialogue, onChange, selectedNodeId, autoOrganize, layoutDirection, reactFlowInstance]);
|
|
239
|
+
// Handle node changes (drag, delete, etc.)
|
|
240
|
+
const onNodesChange = useCallback((changes) => {
|
|
241
|
+
setNodes((nds) => applyNodeChanges(changes, nds));
|
|
242
|
+
// Handle deletions (backup in case onNodesDelete doesn't fire)
|
|
243
|
+
const deletions = changes.filter(c => c.type === 'remove');
|
|
244
|
+
if (deletions.length > 0) {
|
|
245
|
+
let updatedNodes = { ...dialogue.nodes };
|
|
246
|
+
let shouldClearSelection = false;
|
|
247
|
+
deletions.forEach(change => {
|
|
248
|
+
if (change.type === 'remove') {
|
|
249
|
+
delete updatedNodes[change.id];
|
|
250
|
+
if (selectedNodeId === change.id) {
|
|
251
|
+
shouldClearSelection = true;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
onChange({ ...dialogue, nodes: updatedNodes });
|
|
256
|
+
if (shouldClearSelection) {
|
|
257
|
+
setSelectedNodeId(null);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Sync position changes back to DialogueTree
|
|
261
|
+
changes.forEach(change => {
|
|
262
|
+
if (change.type === 'position' && change.position) {
|
|
263
|
+
const node = dialogue.nodes[change.id];
|
|
264
|
+
if (node && (node.x !== change.position.x || node.y !== change.position.y)) {
|
|
265
|
+
// Create a new node object to avoid mutating the original
|
|
266
|
+
const updatedNode = {
|
|
267
|
+
...dialogue.nodes[change.id],
|
|
268
|
+
x: change.position.x,
|
|
269
|
+
y: change.position.y,
|
|
270
|
+
};
|
|
271
|
+
onChange({
|
|
272
|
+
...dialogue,
|
|
273
|
+
nodes: {
|
|
274
|
+
...dialogue.nodes,
|
|
275
|
+
[change.id]: updatedNode,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}, [dialogue, onChange, selectedNodeId]);
|
|
282
|
+
// Handle edge changes (delete, etc.)
|
|
283
|
+
const onEdgesChange = useCallback((changes) => {
|
|
284
|
+
setEdges((eds) => applyEdgeChanges(changes, eds));
|
|
285
|
+
// Sync edge deletions back to DialogueTree
|
|
286
|
+
changes.forEach(change => {
|
|
287
|
+
if (change.type === 'remove') {
|
|
288
|
+
// Find the edge before it's removed
|
|
289
|
+
const currentEdges = edges;
|
|
290
|
+
const edge = currentEdges.find(e => e.id === change.id);
|
|
291
|
+
if (edge) {
|
|
292
|
+
const sourceNode = dialogue.nodes[edge.source];
|
|
293
|
+
if (sourceNode) {
|
|
294
|
+
if (edge.sourceHandle === 'next' && sourceNode.type === 'npc') {
|
|
295
|
+
// Remove NPC next connection
|
|
296
|
+
onChange({
|
|
297
|
+
...dialogue,
|
|
298
|
+
nodes: {
|
|
299
|
+
...dialogue.nodes,
|
|
300
|
+
[edge.source]: {
|
|
301
|
+
...sourceNode,
|
|
302
|
+
nextNodeId: undefined,
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
else if (edge.sourceHandle?.startsWith('choice-')) {
|
|
308
|
+
// Remove Player choice connection
|
|
309
|
+
const choiceIdx = parseInt(edge.sourceHandle.replace('choice-', ''));
|
|
310
|
+
if (sourceNode.choices && sourceNode.choices[choiceIdx]) {
|
|
311
|
+
const updated = updateChoiceInNode(sourceNode, choiceIdx, { nextNodeId: '' });
|
|
312
|
+
onChange({
|
|
313
|
+
...dialogue,
|
|
314
|
+
nodes: {
|
|
315
|
+
...dialogue.nodes,
|
|
316
|
+
[edge.source]: updated,
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else if (edge.sourceHandle?.startsWith('block-') && sourceNode.type === 'conditional') {
|
|
322
|
+
// Remove Conditional block connection
|
|
323
|
+
const blockIdx = parseInt(edge.sourceHandle.replace('block-', ''));
|
|
324
|
+
if (sourceNode.conditionalBlocks && sourceNode.conditionalBlocks[blockIdx]) {
|
|
325
|
+
const updatedBlocks = [...sourceNode.conditionalBlocks];
|
|
326
|
+
updatedBlocks[blockIdx] = {
|
|
327
|
+
...updatedBlocks[blockIdx],
|
|
328
|
+
nextNodeId: undefined,
|
|
329
|
+
};
|
|
330
|
+
onChange({
|
|
331
|
+
...dialogue,
|
|
332
|
+
nodes: {
|
|
333
|
+
...dialogue.nodes,
|
|
334
|
+
[edge.source]: {
|
|
335
|
+
...sourceNode,
|
|
336
|
+
conditionalBlocks: updatedBlocks,
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}, [dialogue, onChange, edges]);
|
|
347
|
+
// Handle edge deletion (when Delete key is pressed on selected edges)
|
|
348
|
+
const onEdgesDelete = useCallback((deletedEdges) => {
|
|
349
|
+
deletedEdges.forEach(edge => {
|
|
350
|
+
// Call onDisconnect hook
|
|
351
|
+
onDisconnect?.(edge.id, edge.source, edge.target);
|
|
352
|
+
const sourceNode = dialogue.nodes[edge.source];
|
|
353
|
+
if (sourceNode) {
|
|
354
|
+
if (edge.sourceHandle === 'next' && sourceNode.type === 'npc') {
|
|
355
|
+
// Remove NPC next connection
|
|
356
|
+
onChange({
|
|
357
|
+
...dialogue,
|
|
358
|
+
nodes: {
|
|
359
|
+
...dialogue.nodes,
|
|
360
|
+
[edge.source]: {
|
|
361
|
+
...sourceNode,
|
|
362
|
+
nextNodeId: undefined,
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
else if (edge.sourceHandle?.startsWith('choice-')) {
|
|
368
|
+
// Remove Player choice connection
|
|
369
|
+
const choiceIdx = parseInt(edge.sourceHandle.replace('choice-', ''));
|
|
370
|
+
if (sourceNode.choices && sourceNode.choices[choiceIdx]) {
|
|
371
|
+
const updated = updateChoiceInNode(sourceNode, choiceIdx, { nextNodeId: '' });
|
|
372
|
+
onChange({
|
|
373
|
+
...dialogue,
|
|
374
|
+
nodes: {
|
|
375
|
+
...dialogue.nodes,
|
|
376
|
+
[edge.source]: updated,
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
else if (edge.sourceHandle?.startsWith('block-') && sourceNode.type === 'conditional') {
|
|
382
|
+
// Remove Conditional block connection
|
|
383
|
+
const blockIdx = parseInt(edge.sourceHandle.replace('block-', ''));
|
|
384
|
+
if (sourceNode.conditionalBlocks && sourceNode.conditionalBlocks[blockIdx]) {
|
|
385
|
+
const updatedBlocks = [...sourceNode.conditionalBlocks];
|
|
386
|
+
updatedBlocks[blockIdx] = {
|
|
387
|
+
...updatedBlocks[blockIdx],
|
|
388
|
+
nextNodeId: undefined,
|
|
389
|
+
};
|
|
390
|
+
onChange({
|
|
391
|
+
...dialogue,
|
|
392
|
+
nodes: {
|
|
393
|
+
...dialogue.nodes,
|
|
394
|
+
[edge.source]: {
|
|
395
|
+
...sourceNode,
|
|
396
|
+
conditionalBlocks: updatedBlocks,
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}, [dialogue, onChange]);
|
|
405
|
+
// Handle connection start (track what we're connecting from)
|
|
406
|
+
const onConnectStart = useCallback((_event, { nodeId, handleId }) => {
|
|
407
|
+
if (!nodeId)
|
|
408
|
+
return;
|
|
409
|
+
const sourceNode = dialogue.nodes[nodeId];
|
|
410
|
+
if (!sourceNode)
|
|
411
|
+
return;
|
|
412
|
+
if (handleId === 'next' && sourceNode.type === 'npc') {
|
|
413
|
+
connectingRef.current = { fromNodeId: nodeId, sourceHandle: 'next' };
|
|
414
|
+
}
|
|
415
|
+
else if (handleId?.startsWith('choice-')) {
|
|
416
|
+
const choiceIdx = parseInt(handleId.replace('choice-', ''));
|
|
417
|
+
connectingRef.current = { fromNodeId: nodeId, fromChoiceIdx: choiceIdx, sourceHandle: handleId };
|
|
418
|
+
}
|
|
419
|
+
else if (handleId?.startsWith('block-')) {
|
|
420
|
+
const blockIdx = parseInt(handleId.replace('block-', ''));
|
|
421
|
+
connectingRef.current = { fromNodeId: nodeId, fromBlockIdx: blockIdx, sourceHandle: handleId };
|
|
422
|
+
}
|
|
423
|
+
}, [dialogue]);
|
|
424
|
+
// Handle connection end (check if dropped on empty space)
|
|
425
|
+
const onConnectEnd = useCallback((event) => {
|
|
426
|
+
if (!connectingRef.current)
|
|
427
|
+
return;
|
|
428
|
+
const targetIsNode = event.target.closest('.react-flow__node');
|
|
429
|
+
if (!targetIsNode) {
|
|
430
|
+
// Dropped on empty space - show edge drop menu
|
|
431
|
+
const clientX = 'clientX' in event ? event.clientX : (event.touches?.[0]?.clientX || 0);
|
|
432
|
+
const clientY = 'clientY' in event ? event.clientY : (event.touches?.[0]?.clientY || 0);
|
|
433
|
+
const point = reactFlowInstance.screenToFlowPosition({
|
|
434
|
+
x: clientX,
|
|
435
|
+
y: clientY,
|
|
436
|
+
});
|
|
437
|
+
setEdgeDropMenu({
|
|
438
|
+
x: clientX,
|
|
439
|
+
y: clientY,
|
|
440
|
+
graphX: point.x,
|
|
441
|
+
graphY: point.y,
|
|
442
|
+
fromNodeId: connectingRef.current.fromNodeId,
|
|
443
|
+
fromChoiceIdx: connectingRef.current.fromChoiceIdx,
|
|
444
|
+
fromBlockIdx: connectingRef.current.fromBlockIdx,
|
|
445
|
+
sourceHandle: connectingRef.current.sourceHandle,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
connectingRef.current = null;
|
|
449
|
+
}, [reactFlowInstance]);
|
|
450
|
+
// Handle new connections
|
|
451
|
+
const onConnect = useCallback((connection) => {
|
|
452
|
+
if (!connection.source || !connection.target)
|
|
453
|
+
return;
|
|
454
|
+
const newEdge = addEdge(connection, edges);
|
|
455
|
+
setEdges(newEdge);
|
|
456
|
+
setEdgeDropMenu(null); // Close edge drop menu if open
|
|
457
|
+
// Call onConnect hook
|
|
458
|
+
onConnectHook?.(connection.source, connection.target, connection.sourceHandle || undefined);
|
|
459
|
+
// Update DialogueTree
|
|
460
|
+
const sourceNode = dialogue.nodes[connection.source];
|
|
461
|
+
if (!sourceNode)
|
|
462
|
+
return;
|
|
463
|
+
if (connection.sourceHandle === 'next' && sourceNode.type === 'npc') {
|
|
464
|
+
// NPC next connection
|
|
465
|
+
onChange({
|
|
466
|
+
...dialogue,
|
|
467
|
+
nodes: {
|
|
468
|
+
...dialogue.nodes,
|
|
469
|
+
[connection.source]: {
|
|
470
|
+
...sourceNode,
|
|
471
|
+
nextNodeId: connection.target,
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
else if (connection.sourceHandle?.startsWith('choice-')) {
|
|
477
|
+
// Player choice connection
|
|
478
|
+
const choiceIdx = parseInt(connection.sourceHandle.replace('choice-', ''));
|
|
479
|
+
if (sourceNode.choices && sourceNode.choices[choiceIdx]) {
|
|
480
|
+
const updated = updateChoiceInNode(sourceNode, choiceIdx, { nextNodeId: connection.target });
|
|
481
|
+
onChange({
|
|
482
|
+
...dialogue,
|
|
483
|
+
nodes: {
|
|
484
|
+
...dialogue.nodes,
|
|
485
|
+
[connection.source]: updated,
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
else if (connection.sourceHandle?.startsWith('block-') && sourceNode.type === 'conditional') {
|
|
491
|
+
// Conditional block connection
|
|
492
|
+
const blockIdx = parseInt(connection.sourceHandle.replace('block-', ''));
|
|
493
|
+
if (sourceNode.conditionalBlocks && sourceNode.conditionalBlocks[blockIdx]) {
|
|
494
|
+
const updatedBlocks = [...sourceNode.conditionalBlocks];
|
|
495
|
+
updatedBlocks[blockIdx] = {
|
|
496
|
+
...updatedBlocks[blockIdx],
|
|
497
|
+
nextNodeId: connection.target,
|
|
498
|
+
};
|
|
499
|
+
onChange({
|
|
500
|
+
...dialogue,
|
|
501
|
+
nodes: {
|
|
502
|
+
...dialogue.nodes,
|
|
503
|
+
[connection.source]: {
|
|
504
|
+
...sourceNode,
|
|
505
|
+
conditionalBlocks: updatedBlocks,
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
connectingRef.current = null;
|
|
512
|
+
}, [dialogue, onChange, edges]);
|
|
513
|
+
// Handle node selection
|
|
514
|
+
const onNodeClick = useCallback((_event, node) => {
|
|
515
|
+
setSelectedNodeId(node.id);
|
|
516
|
+
setNodeContextMenu(null);
|
|
517
|
+
onNodeSelect?.(node.id);
|
|
518
|
+
}, [onNodeSelect]);
|
|
519
|
+
// Handle node double-click - zoom to node
|
|
520
|
+
const onNodeDoubleClick = useCallback((_event, node) => {
|
|
521
|
+
if (reactFlowInstance) {
|
|
522
|
+
reactFlowInstance.setCenter(node.position.x + 110, // Half of NODE_WIDTH
|
|
523
|
+
node.position.y + 60, // Half of NODE_HEIGHT
|
|
524
|
+
{ zoom: 1.5, duration: 500 });
|
|
525
|
+
}
|
|
526
|
+
onNodeDoubleClickHook?.(node.id);
|
|
527
|
+
}, [reactFlowInstance, onNodeDoubleClickHook]);
|
|
528
|
+
// Handle pane double-click - fit view to all nodes (like default zoom)
|
|
529
|
+
// We'll handle this via useEffect since React Flow doesn't have onPaneDoubleClick
|
|
530
|
+
const reactFlowWrapperRef = useRef(null);
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
const handleDoubleClick = (event) => {
|
|
533
|
+
// Check if clicking on the pane (not on a node or edge)
|
|
534
|
+
const target = event.target;
|
|
535
|
+
if (target.closest('.react-flow__node') || target.closest('.react-flow__edge')) {
|
|
536
|
+
return; // Don't handle if clicking on node/edge
|
|
537
|
+
}
|
|
538
|
+
if (reactFlowInstance) {
|
|
539
|
+
reactFlowInstance.fitView({ padding: 0.2, duration: 500 });
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
const container = reactFlowWrapperRef.current;
|
|
543
|
+
if (container) {
|
|
544
|
+
container.addEventListener('dblclick', handleDoubleClick);
|
|
545
|
+
return () => {
|
|
546
|
+
container.removeEventListener('dblclick', handleDoubleClick);
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
}, [reactFlowInstance]);
|
|
550
|
+
// Track mouse wheel clicks for double-click detection
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
const handleMouseDown = (event) => {
|
|
553
|
+
const mouseEvent = event;
|
|
554
|
+
if (mouseEvent.button === 1) { // Middle mouse button (wheel)
|
|
555
|
+
const now = Date.now();
|
|
556
|
+
if (now - lastWheelClickRef.current < 300) {
|
|
557
|
+
// Double-click detected - fit view
|
|
558
|
+
mouseEvent.preventDefault();
|
|
559
|
+
if (reactFlowInstance) {
|
|
560
|
+
reactFlowInstance.fitView({ padding: 0.2, duration: 500 });
|
|
561
|
+
}
|
|
562
|
+
lastWheelClickRef.current = 0;
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
lastWheelClickRef.current = now;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
const container = document.querySelector('.react-flow');
|
|
570
|
+
if (container) {
|
|
571
|
+
container.addEventListener('mousedown', handleMouseDown);
|
|
572
|
+
return () => {
|
|
573
|
+
container.removeEventListener('mousedown', handleMouseDown);
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
}, [reactFlowInstance]);
|
|
577
|
+
// Handle pane context menu (right-click on empty space)
|
|
578
|
+
const onPaneContextMenu = useCallback((event) => {
|
|
579
|
+
event.preventDefault();
|
|
580
|
+
const point = reactFlowInstance.screenToFlowPosition({
|
|
581
|
+
x: event.clientX,
|
|
582
|
+
y: event.clientY,
|
|
583
|
+
});
|
|
584
|
+
setContextMenu({
|
|
585
|
+
x: event.clientX,
|
|
586
|
+
y: event.clientY,
|
|
587
|
+
graphX: point.x,
|
|
588
|
+
graphY: point.y,
|
|
589
|
+
});
|
|
590
|
+
}, [reactFlowInstance]);
|
|
591
|
+
// Handle node context menu
|
|
592
|
+
const onNodeContextMenu = useCallback((event, node) => {
|
|
593
|
+
event.preventDefault();
|
|
594
|
+
setNodeContextMenu({
|
|
595
|
+
x: event.clientX,
|
|
596
|
+
y: event.clientY,
|
|
597
|
+
nodeId: node.id,
|
|
598
|
+
});
|
|
599
|
+
setContextMenu(null);
|
|
600
|
+
}, []);
|
|
601
|
+
// Handle edge context menu (right-click on edge to insert node)
|
|
602
|
+
const onEdgeContextMenu = useCallback((event, edge) => {
|
|
603
|
+
event.preventDefault();
|
|
604
|
+
// Calculate midpoint position on the edge
|
|
605
|
+
const sourceNodePosition = nodes.find(n => n.id === edge.source)?.position;
|
|
606
|
+
const targetNodePosition = nodes.find(n => n.id === edge.target)?.position;
|
|
607
|
+
if (!sourceNodePosition || !targetNodePosition)
|
|
608
|
+
return;
|
|
609
|
+
// Calculate midpoint in flow coordinates
|
|
610
|
+
const midX = (sourceNodePosition.x + targetNodePosition.x) / 2;
|
|
611
|
+
const midY = (sourceNodePosition.y + targetNodePosition.y) / 2;
|
|
612
|
+
// Convert to screen coordinates for menu positioning
|
|
613
|
+
const point = reactFlowInstance.flowToScreenPosition({ x: midX, y: midY });
|
|
614
|
+
setEdgeContextMenu({
|
|
615
|
+
x: point.x,
|
|
616
|
+
y: point.y,
|
|
617
|
+
edgeId: edge.id,
|
|
618
|
+
graphX: midX,
|
|
619
|
+
graphY: midY,
|
|
620
|
+
});
|
|
621
|
+
setContextMenu(null);
|
|
622
|
+
setNodeContextMenu(null);
|
|
623
|
+
}, [nodes, reactFlowInstance]);
|
|
624
|
+
// Insert node between two connected nodes
|
|
625
|
+
const handleInsertNode = useCallback((type, edgeId, x, y) => {
|
|
626
|
+
// Find the edge
|
|
627
|
+
const edge = edges.find(e => e.id === edgeId);
|
|
628
|
+
if (!edge)
|
|
629
|
+
return;
|
|
630
|
+
// Get the source and target nodes
|
|
631
|
+
const sourceNode = dialogue.nodes[edge.source];
|
|
632
|
+
const targetNode = dialogue.nodes[edge.target];
|
|
633
|
+
if (!sourceNode || !targetNode)
|
|
634
|
+
return;
|
|
635
|
+
// Create new node
|
|
636
|
+
const newId = `${type}_${Date.now()}`;
|
|
637
|
+
const newNode = createNode(type, newId, x, y);
|
|
638
|
+
// Update dialogue tree: break old connection, add new node, connect source->new->target
|
|
639
|
+
const updatedNodes = { ...dialogue.nodes, [newId]: newNode };
|
|
640
|
+
// Break the old connection and reconnect through new node
|
|
641
|
+
if (edge.sourceHandle === 'next' && sourceNode.type === 'npc') {
|
|
642
|
+
// NPC connection
|
|
643
|
+
updatedNodes[edge.source] = {
|
|
644
|
+
...sourceNode,
|
|
645
|
+
nextNodeId: newId, // Connect source to new node
|
|
646
|
+
};
|
|
647
|
+
updatedNodes[newId] = {
|
|
648
|
+
...newNode,
|
|
649
|
+
nextNodeId: edge.target, // Connect new node to target
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
else if (edge.sourceHandle?.startsWith('choice-')) {
|
|
653
|
+
// Player choice connection
|
|
654
|
+
const choiceIdx = parseInt(edge.sourceHandle.replace('choice-', ''));
|
|
655
|
+
if (sourceNode.choices && sourceNode.choices[choiceIdx]) {
|
|
656
|
+
const updatedChoices = [...sourceNode.choices];
|
|
657
|
+
updatedChoices[choiceIdx] = {
|
|
658
|
+
...updatedChoices[choiceIdx],
|
|
659
|
+
nextNodeId: newId, // Connect choice to new node
|
|
660
|
+
};
|
|
661
|
+
updatedNodes[edge.source] = {
|
|
662
|
+
...sourceNode,
|
|
663
|
+
choices: updatedChoices,
|
|
664
|
+
};
|
|
665
|
+
updatedNodes[newId] = {
|
|
666
|
+
...newNode,
|
|
667
|
+
nextNodeId: edge.target, // Connect new node to target
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
else if (edge.sourceHandle?.startsWith('block-')) {
|
|
672
|
+
// Conditional block connection
|
|
673
|
+
const blockIdx = parseInt(edge.sourceHandle.replace('block-', ''));
|
|
674
|
+
if (sourceNode.conditionalBlocks && sourceNode.conditionalBlocks[blockIdx]) {
|
|
675
|
+
const updatedBlocks = [...sourceNode.conditionalBlocks];
|
|
676
|
+
updatedBlocks[blockIdx] = {
|
|
677
|
+
...updatedBlocks[blockIdx],
|
|
678
|
+
nextNodeId: newId, // Connect block to new node
|
|
679
|
+
};
|
|
680
|
+
updatedNodes[edge.source] = {
|
|
681
|
+
...sourceNode,
|
|
682
|
+
conditionalBlocks: updatedBlocks,
|
|
683
|
+
};
|
|
684
|
+
updatedNodes[newId] = {
|
|
685
|
+
...newNode,
|
|
686
|
+
nextNodeId: edge.target, // Connect new node to target
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
onChange({
|
|
691
|
+
...dialogue,
|
|
692
|
+
nodes: updatedNodes,
|
|
693
|
+
});
|
|
694
|
+
setEdgeContextMenu(null);
|
|
695
|
+
}, [dialogue, onChange, edges]);
|
|
696
|
+
// Add node from context menu or edge drop
|
|
697
|
+
const handleAddNode = useCallback((type, x, y, autoConnect) => {
|
|
698
|
+
const newId = `${type}_${Date.now()}`;
|
|
699
|
+
const newNode = createNode(type, newId, x, y);
|
|
700
|
+
// Call onNodeAdd hook
|
|
701
|
+
onNodeAdd?.(newNode);
|
|
702
|
+
// Build the complete new dialogue state in one go
|
|
703
|
+
let newDialogue = {
|
|
704
|
+
...dialogue,
|
|
705
|
+
nodes: { ...dialogue.nodes, [newId]: newNode }
|
|
706
|
+
};
|
|
707
|
+
// If auto-connecting, include that connection
|
|
708
|
+
if (autoConnect) {
|
|
709
|
+
const sourceNode = dialogue.nodes[autoConnect.fromNodeId];
|
|
710
|
+
if (sourceNode) {
|
|
711
|
+
if (autoConnect.sourceHandle === 'next' && sourceNode.type === 'npc') {
|
|
712
|
+
newDialogue.nodes[autoConnect.fromNodeId] = { ...sourceNode, nextNodeId: newId };
|
|
713
|
+
}
|
|
714
|
+
else if (autoConnect.fromChoiceIdx !== undefined && sourceNode.choices) {
|
|
715
|
+
const newChoices = [...sourceNode.choices];
|
|
716
|
+
newChoices[autoConnect.fromChoiceIdx] = { ...newChoices[autoConnect.fromChoiceIdx], nextNodeId: newId };
|
|
717
|
+
newDialogue.nodes[autoConnect.fromNodeId] = { ...sourceNode, choices: newChoices };
|
|
718
|
+
}
|
|
719
|
+
else if (autoConnect.fromBlockIdx !== undefined && sourceNode.type === 'conditional' && sourceNode.conditionalBlocks) {
|
|
720
|
+
const newBlocks = [...sourceNode.conditionalBlocks];
|
|
721
|
+
newBlocks[autoConnect.fromBlockIdx] = { ...newBlocks[autoConnect.fromBlockIdx], nextNodeId: newId };
|
|
722
|
+
newDialogue.nodes[autoConnect.fromNodeId] = { ...sourceNode, conditionalBlocks: newBlocks };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
// Apply layout if auto-organize is enabled
|
|
727
|
+
if (autoOrganize) {
|
|
728
|
+
const result = applyLayout(newDialogue, layoutStrategy, { direction: layoutDirection });
|
|
729
|
+
newDialogue = result.dialogue;
|
|
730
|
+
}
|
|
731
|
+
// Single onChange call with all updates
|
|
732
|
+
onChange(newDialogue);
|
|
733
|
+
setSelectedNodeId(newId);
|
|
734
|
+
setContextMenu(null);
|
|
735
|
+
setEdgeDropMenu(null);
|
|
736
|
+
connectingRef.current = null;
|
|
737
|
+
// Fit view after layout (only if auto-organize is on)
|
|
738
|
+
if (autoOrganize) {
|
|
739
|
+
setTimeout(() => {
|
|
740
|
+
if (reactFlowInstance) {
|
|
741
|
+
reactFlowInstance.fitView({ padding: 0.2, duration: 300 });
|
|
742
|
+
}
|
|
743
|
+
}, 50);
|
|
744
|
+
}
|
|
745
|
+
}, [dialogue, onChange, autoOrganize, layoutDirection, reactFlowInstance]);
|
|
746
|
+
// Handle node updates
|
|
747
|
+
const handleUpdateNode = useCallback((nodeId, updates) => {
|
|
748
|
+
const updatedNode = { ...dialogue.nodes[nodeId], ...updates };
|
|
749
|
+
onChange({
|
|
750
|
+
...dialogue,
|
|
751
|
+
nodes: {
|
|
752
|
+
...dialogue.nodes,
|
|
753
|
+
[nodeId]: updatedNode
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
// Call onNodeUpdate hook
|
|
757
|
+
onNodeUpdate?.(nodeId, updates);
|
|
758
|
+
}, [dialogue, onChange, onNodeUpdate]);
|
|
759
|
+
// Handle choice updates
|
|
760
|
+
const handleAddChoice = useCallback((nodeId) => {
|
|
761
|
+
const updated = addChoiceToNode(dialogue.nodes[nodeId]);
|
|
762
|
+
handleUpdateNode(nodeId, updated);
|
|
763
|
+
}, [dialogue, handleUpdateNode]);
|
|
764
|
+
const handleUpdateChoice = useCallback((nodeId, choiceIdx, updates) => {
|
|
765
|
+
const updated = updateChoiceInNode(dialogue.nodes[nodeId], choiceIdx, updates);
|
|
766
|
+
handleUpdateNode(nodeId, updated);
|
|
767
|
+
}, [dialogue, handleUpdateNode]);
|
|
768
|
+
const handleRemoveChoice = useCallback((nodeId, choiceIdx) => {
|
|
769
|
+
const updated = removeChoiceFromNode(dialogue.nodes[nodeId], choiceIdx);
|
|
770
|
+
handleUpdateNode(nodeId, updated);
|
|
771
|
+
}, [dialogue, handleUpdateNode]);
|
|
772
|
+
const handleDeleteNode = useCallback((nodeId) => {
|
|
773
|
+
try {
|
|
774
|
+
let newDialogue = deleteNodeFromTree(dialogue, nodeId);
|
|
775
|
+
// Auto-organize if enabled
|
|
776
|
+
if (autoOrganize) {
|
|
777
|
+
const result = applyLayout(newDialogue, layoutStrategy, { direction: layoutDirection });
|
|
778
|
+
newDialogue = result.dialogue;
|
|
779
|
+
setTimeout(() => {
|
|
780
|
+
if (reactFlowInstance) {
|
|
781
|
+
reactFlowInstance.fitView({ padding: 0.2, duration: 300 });
|
|
782
|
+
}
|
|
783
|
+
}, 50);
|
|
784
|
+
}
|
|
785
|
+
onChange(newDialogue);
|
|
786
|
+
setSelectedNodeId(null);
|
|
787
|
+
}
|
|
788
|
+
catch (e) {
|
|
789
|
+
alert(e.message);
|
|
790
|
+
}
|
|
791
|
+
}, [dialogue, onChange, autoOrganize, layoutDirection, reactFlowInstance]);
|
|
792
|
+
// Handle node drag stop - resolve collisions in freeform mode
|
|
793
|
+
const onNodeDragStop = useCallback((event, node) => {
|
|
794
|
+
// In freeform mode, resolve collisions after drag
|
|
795
|
+
if (!autoOrganize) {
|
|
796
|
+
const collisionResolved = resolveNodeCollisions(dialogue, {
|
|
797
|
+
maxIterations: 50,
|
|
798
|
+
overlapThreshold: 0.3,
|
|
799
|
+
margin: 20,
|
|
800
|
+
});
|
|
801
|
+
// Only update if positions actually changed
|
|
802
|
+
const hasChanges = Object.keys(collisionResolved.nodes).some(id => {
|
|
803
|
+
const orig = dialogue.nodes[id];
|
|
804
|
+
const resolved = collisionResolved.nodes[id];
|
|
805
|
+
return orig && resolved && (orig.x !== resolved.x || orig.y !== resolved.y);
|
|
806
|
+
});
|
|
807
|
+
if (hasChanges) {
|
|
808
|
+
onChange(collisionResolved);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}, [dialogue, onChange, autoOrganize]);
|
|
812
|
+
// Handle auto-layout with direction (strategy comes from prop)
|
|
813
|
+
const handleAutoLayout = useCallback((direction) => {
|
|
814
|
+
const dir = direction || layoutDirection;
|
|
815
|
+
if (direction) {
|
|
816
|
+
setLayoutDirection(direction);
|
|
817
|
+
}
|
|
818
|
+
const result = applyLayout(dialogue, layoutStrategy, { direction: dir });
|
|
819
|
+
onChange(result.dialogue);
|
|
820
|
+
// Fit view after a short delay to allow React Flow to update
|
|
821
|
+
setTimeout(() => {
|
|
822
|
+
if (reactFlowInstance) {
|
|
823
|
+
reactFlowInstance.fitView({ padding: 0.2, duration: 500 });
|
|
824
|
+
}
|
|
825
|
+
}, 100);
|
|
826
|
+
}, [dialogue, onChange, reactFlowInstance, layoutDirection, layoutStrategy]);
|
|
827
|
+
return (React.createElement("div", { className: `dialogue-editor-v2 ${className} w-full h-full flex flex-col` },
|
|
828
|
+
viewMode === 'graph' && (React.createElement("div", { className: "flex-1 flex overflow-hidden" },
|
|
829
|
+
React.createElement("div", { className: "flex-1 relative w-full h-full", ref: reactFlowWrapperRef, style: { minHeight: 0 } },
|
|
830
|
+
React.createElement(ReactFlow, { nodes: nodesWithFlags, edges: edges.map(edge => {
|
|
831
|
+
// Detect back-edges (loops) based on layout direction
|
|
832
|
+
const sourceNode = nodes.find(n => n.id === edge.source);
|
|
833
|
+
const targetNode = nodes.find(n => n.id === edge.target);
|
|
834
|
+
// For TB layout: back-edge if target Y < source Y (going up)
|
|
835
|
+
// For LR layout: back-edge if target X < source X (going left)
|
|
836
|
+
const isBackEdge = showBackEdges && sourceNode && targetNode && (layoutDirection === 'TB'
|
|
837
|
+
? targetNode.position.y < sourceNode.position.y
|
|
838
|
+
: targetNode.position.x < sourceNode.position.x);
|
|
839
|
+
const isInPath = edgesToSelectedNode.has(edge.id);
|
|
840
|
+
// Dim edges not in the path when path highlighting is on and something is selected
|
|
841
|
+
const isDimmed = showPathHighlight && selectedNodeId !== null && !isInPath;
|
|
842
|
+
return {
|
|
843
|
+
...edge,
|
|
844
|
+
data: {
|
|
845
|
+
...edge.data,
|
|
846
|
+
isInPathToSelected: showPathHighlight && isInPath,
|
|
847
|
+
isBackEdge,
|
|
848
|
+
isDimmed,
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
}), nodeTypes: memoizedNodeTypes, edgeTypes: memoizedEdgeTypes, onNodesChange: onNodesChange, onEdgesChange: onEdgesChange, onNodesDelete: onNodesDelete, onEdgesDelete: onEdgesDelete, onNodeDragStop: onNodeDragStop, nodesDraggable: !autoOrganize, onConnect: onConnect, onConnectStart: onConnectStart, onConnectEnd: onConnectEnd, onNodeClick: onNodeClick, onNodeDoubleClick: onNodeDoubleClick, onPaneContextMenu: onPaneContextMenu, onNodeContextMenu: onNodeContextMenu, onEdgeContextMenu: onEdgeContextMenu, onPaneClick: () => {
|
|
852
|
+
// Close context menus and deselect node when clicking on pane (not nodes)
|
|
853
|
+
setContextMenu(null);
|
|
854
|
+
setNodeContextMenu(null);
|
|
855
|
+
setSelectedNodeId(null);
|
|
856
|
+
setShowLayoutMenu(false);
|
|
857
|
+
}, fitView: true, className: "bg-df-canvas-bg", style: { background: 'radial-gradient(circle, var(--color-df-canvas-grid) 1px, var(--color-df-canvas-bg) 1px)', backgroundSize: '20px 20px' }, defaultEdgeOptions: { type: 'default' }, connectionLineStyle: { stroke: '#e94560', strokeWidth: 2 }, connectionLineType: ConnectionLineType.SmoothStep, snapToGrid: false, nodesConnectable: true, elementsSelectable: true, selectionOnDrag: true, panOnDrag: true, panOnScroll: true, zoomOnScroll: true, zoomOnPinch: true, preventScrolling: false,
|
|
858
|
+
// Behavior:
|
|
859
|
+
// - Click and drag a node = moves the node (React Flow handles this automatically)
|
|
860
|
+
// - Click and drag empty space = pans canvas
|
|
861
|
+
// - Trackpad two-finger swipe = pans canvas (works with panOnDrag)
|
|
862
|
+
// - Scroll wheel/trackpad scroll = zooms
|
|
863
|
+
// - Shift+Scroll = pans
|
|
864
|
+
// Note: React Flow automatically detects if you're dragging a node vs empty space
|
|
865
|
+
zoomOnDoubleClick: false, minZoom: 0.1, maxZoom: 3, deleteKeyCode: ['Delete', 'Backspace'], tabIndex: 0 },
|
|
866
|
+
React.createElement(Background, { variant: BackgroundVariant.Dots, gap: 20, size: 1, color: "#1a1a2e" }),
|
|
867
|
+
React.createElement(Panel, { position: "bottom-right", className: "!p-0 !m-2" },
|
|
868
|
+
React.createElement("div", { className: "bg-df-sidebar-bg border border-df-sidebar-border rounded-lg overflow-hidden shadow-xl" },
|
|
869
|
+
React.createElement("div", { className: "px-3 py-1.5 border-b border-df-sidebar-border flex items-center justify-between bg-df-elevated" },
|
|
870
|
+
React.createElement("span", { className: "text-[10px] font-medium text-df-text-secondary uppercase tracking-wider" }, "Overview"),
|
|
871
|
+
React.createElement("div", { className: "flex items-center gap-1" },
|
|
872
|
+
React.createElement("span", { className: "w-2 h-2 rounded-full bg-df-npc-selected", title: "NPC Node" }),
|
|
873
|
+
React.createElement("span", { className: "w-2 h-2 rounded-full bg-df-player-selected", title: "Player Node" }),
|
|
874
|
+
React.createElement("span", { className: "w-2 h-2 rounded-full bg-df-conditional-border", title: "Conditional" }))),
|
|
875
|
+
React.createElement(MiniMap, { style: {
|
|
876
|
+
width: 180,
|
|
877
|
+
height: 120,
|
|
878
|
+
backgroundColor: '#08080c',
|
|
879
|
+
}, maskColor: "rgba(0, 0, 0, 0.7)", nodeColor: (node) => {
|
|
880
|
+
if (node.type === 'npc')
|
|
881
|
+
return '#e94560';
|
|
882
|
+
if (node.type === 'player')
|
|
883
|
+
return '#8b5cf6';
|
|
884
|
+
if (node.type === 'conditional')
|
|
885
|
+
return '#3b82f6';
|
|
886
|
+
return '#4a4a6a';
|
|
887
|
+
}, nodeStrokeWidth: 2, pannable: true, zoomable: true }))),
|
|
888
|
+
React.createElement(Panel, { position: "top-left", className: "!bg-transparent !border-0 !p-0 !m-2" },
|
|
889
|
+
React.createElement("div", { className: "flex flex-col gap-1.5 bg-df-sidebar-bg border border-df-sidebar-border rounded-lg p-1.5 shadow-lg" },
|
|
890
|
+
React.createElement("div", { className: "relative" },
|
|
891
|
+
React.createElement("button", { onClick: () => setShowLayoutMenu(!showLayoutMenu), className: `p-1.5 rounded transition-colors ${showLayoutMenu
|
|
892
|
+
? 'bg-df-npc-selected/20 text-df-npc-selected border border-df-npc-selected'
|
|
893
|
+
: 'bg-df-elevated border border-df-control-border text-df-text-secondary hover:text-df-text-primary hover:border-df-control-hover'}`, title: `Layout: ${listLayouts().find(l => l.id === layoutStrategy)?.name || layoutStrategy}` },
|
|
894
|
+
React.createElement(Grid3x3, { size: 14 })),
|
|
895
|
+
showLayoutMenu && (React.createElement("div", { className: "absolute left-full ml-2 top-0 z-50 bg-df-sidebar-bg border border-df-sidebar-border rounded-lg shadow-xl p-1 min-w-[200px]" },
|
|
896
|
+
React.createElement("div", { className: "text-[10px] text-df-text-secondary uppercase tracking-wider px-2 py-1 border-b border-df-sidebar-border" }, "Layout Algorithm"),
|
|
897
|
+
listLayouts().map(layout => (React.createElement("button", { key: layout.id, onClick: () => {
|
|
898
|
+
if (onLayoutStrategyChange) {
|
|
899
|
+
onLayoutStrategyChange(layout.id);
|
|
900
|
+
setShowLayoutMenu(false);
|
|
901
|
+
// Trigger layout update with new strategy
|
|
902
|
+
setTimeout(() => handleAutoLayout(), 0);
|
|
903
|
+
}
|
|
904
|
+
}, className: `w-full text-left px-3 py-2 text-sm rounded transition-colors ${layoutStrategy === layout.id
|
|
905
|
+
? 'bg-df-npc-selected/20 text-df-npc-selected'
|
|
906
|
+
: 'text-df-text-primary hover:bg-df-elevated'}` },
|
|
907
|
+
React.createElement("div", { className: "font-medium" },
|
|
908
|
+
layout.name,
|
|
909
|
+
" ",
|
|
910
|
+
layout.isDefault && '(default)'),
|
|
911
|
+
React.createElement("div", { className: "text-[10px] text-df-text-secondary mt-0.5" }, layout.description))))))),
|
|
912
|
+
onOpenFlagManager && (React.createElement("button", { onClick: onOpenFlagManager, className: "p-1.5 bg-df-elevated border border-df-control-border rounded text-df-text-secondary hover:text-df-text-primary hover:border-df-control-hover transition-colors", title: "Manage Flags" },
|
|
913
|
+
React.createElement(Settings, { size: 14 }))),
|
|
914
|
+
onOpenGuide && (React.createElement("button", { onClick: onOpenGuide, className: "p-1.5 bg-df-elevated border border-df-control-border rounded text-df-text-secondary hover:text-df-text-primary hover:border-df-control-hover transition-colors", title: "Guide & Documentation" },
|
|
915
|
+
React.createElement(BookOpen, { size: 14 }))),
|
|
916
|
+
ENABLE_DEBUG_TOOLS && onLoadExampleDialogue && onLoadExampleFlags && (React.createElement(ExampleLoaderButton, { onLoadDialogue: onLoadExampleDialogue, onLoadFlags: onLoadExampleFlags })))),
|
|
917
|
+
React.createElement(Panel, { position: "top-right", className: "!bg-transparent !border-0 !p-0 !m-2" },
|
|
918
|
+
React.createElement("div", { className: "flex items-center gap-1.5 bg-df-sidebar-bg border border-df-sidebar-border rounded-lg p-1.5 shadow-lg" },
|
|
919
|
+
React.createElement("button", { onClick: () => {
|
|
920
|
+
const newAutoOrganize = !autoOrganize;
|
|
921
|
+
setAutoOrganize(newAutoOrganize);
|
|
922
|
+
// If turning on, immediately apply layout
|
|
923
|
+
if (newAutoOrganize) {
|
|
924
|
+
handleAutoLayout();
|
|
925
|
+
}
|
|
926
|
+
}, className: `p-1.5 rounded transition-colors ${autoOrganize
|
|
927
|
+
? 'bg-df-success/20 text-df-success border border-df-success'
|
|
928
|
+
: 'bg-df-elevated text-df-text-secondary hover:text-df-text-primary border border-df-control-border'}`, title: autoOrganize ? `Auto Layout ON - Nodes auto-arrange` : "Auto Layout OFF - Free placement" },
|
|
929
|
+
React.createElement(Magnet, { size: 14 })),
|
|
930
|
+
React.createElement("div", { className: "w-px h-5 bg-df-control-border" }),
|
|
931
|
+
React.createElement("div", { className: "flex border border-df-control-border rounded overflow-hidden" },
|
|
932
|
+
React.createElement("button", { onClick: () => handleAutoLayout('TB'), className: `p-1.5 transition-colors ${layoutDirection === 'TB'
|
|
933
|
+
? 'bg-df-npc-selected/20 text-df-npc-selected'
|
|
934
|
+
: 'bg-df-elevated text-df-text-secondary hover:text-df-text-primary'} border-r border-df-control-border`, title: "Vertical Layout (Top to Bottom)" },
|
|
935
|
+
React.createElement(ArrowDown, { size: 14 })),
|
|
936
|
+
React.createElement("button", { onClick: () => handleAutoLayout('LR'), className: `p-1.5 transition-colors ${layoutDirection === 'LR'
|
|
937
|
+
? 'bg-df-player-selected/20 text-df-player-selected'
|
|
938
|
+
: 'bg-df-elevated text-df-text-secondary hover:text-df-text-primary'}`, title: "Horizontal Layout (Left to Right)" },
|
|
939
|
+
React.createElement(ArrowRight, { size: 14 }))),
|
|
940
|
+
React.createElement("button", { onClick: () => handleAutoLayout(), className: "p-1.5 bg-df-elevated border border-df-control-border rounded text-df-text-secondary hover:text-df-text-primary hover:border-df-control-hover transition-colors", title: "Re-apply Layout" },
|
|
941
|
+
React.createElement(Layout, { size: 14 })),
|
|
942
|
+
React.createElement("div", { className: "w-px h-5 bg-df-control-border" }),
|
|
943
|
+
React.createElement("button", { onClick: () => setShowPathHighlight(!showPathHighlight), className: `p-1.5 rounded transition-colors ${showPathHighlight
|
|
944
|
+
? 'bg-df-info/20 text-df-info border border-df-info'
|
|
945
|
+
: 'bg-df-elevated text-df-text-secondary hover:text-df-text-primary border border-df-control-border'}`, title: showPathHighlight ? "Path Highlight ON" : "Path Highlight OFF" },
|
|
946
|
+
React.createElement(Sparkles, { size: 14 })),
|
|
947
|
+
React.createElement("button", { onClick: () => setShowBackEdges(!showBackEdges), className: `p-1.5 rounded transition-colors ${showBackEdges
|
|
948
|
+
? 'bg-df-warning/20 text-df-warning border border-df-warning'
|
|
949
|
+
: 'bg-df-elevated text-df-text-secondary hover:text-df-text-primary border border-df-control-border'}`, title: showBackEdges ? "Loop Edges Styled" : "Loop Edges Normal" },
|
|
950
|
+
React.createElement(Undo2, { size: 14 })),
|
|
951
|
+
React.createElement("div", { className: "w-px h-5 bg-df-control-border" }),
|
|
952
|
+
React.createElement("button", { onClick: () => {
|
|
953
|
+
if (dialogue?.startNodeId) {
|
|
954
|
+
setSelectedNodeId(dialogue.startNodeId);
|
|
955
|
+
// Center on start node
|
|
956
|
+
const startNode = nodes.find(n => n.id === dialogue.startNodeId);
|
|
957
|
+
if (startNode && reactFlowInstance) {
|
|
958
|
+
reactFlowInstance.setCenter(startNode.position.x + 110, startNode.position.y + 60, { zoom: 1, duration: 500 });
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}, className: "p-1.5 bg-df-start/20 text-df-start border border-df-start rounded transition-colors hover:bg-df-start/30", title: "Go to Start Node" },
|
|
962
|
+
React.createElement(Home, { size: 14 })),
|
|
963
|
+
React.createElement("button", { onClick: () => {
|
|
964
|
+
const endNodes = Array.from(endNodeIds);
|
|
965
|
+
if (endNodes.length > 0) {
|
|
966
|
+
// Cycle through end nodes or select first one
|
|
967
|
+
const currentIdx = selectedNodeId ? endNodes.indexOf(selectedNodeId) : -1;
|
|
968
|
+
const nextIdx = (currentIdx + 1) % endNodes.length;
|
|
969
|
+
const nextEndNodeId = endNodes[nextIdx];
|
|
970
|
+
setSelectedNodeId(nextEndNodeId);
|
|
971
|
+
// Center on end node
|
|
972
|
+
const endNode = nodes.find(n => n.id === nextEndNodeId);
|
|
973
|
+
if (endNode && reactFlowInstance) {
|
|
974
|
+
reactFlowInstance.setCenter(endNode.position.x + 110, endNode.position.y + 60, { zoom: 1, duration: 500 });
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}, className: "p-1.5 bg-df-end/20 text-df-end border border-df-end rounded transition-colors hover:bg-df-end/30", title: `Go to End Node (${endNodeIds.size} total)` },
|
|
978
|
+
React.createElement(Flag, { size: 14 })))),
|
|
979
|
+
contextMenu && (React.createElement("div", { className: "fixed z-50", style: { left: contextMenu.x, top: contextMenu.y } },
|
|
980
|
+
React.createElement("div", { className: "bg-df-sidebar-bg border border-df-sidebar-border rounded-lg shadow-lg p-1 min-w-[150px]" },
|
|
981
|
+
React.createElement("button", { onClick: () => {
|
|
982
|
+
handleAddNode('npc', contextMenu.graphX, contextMenu.graphY);
|
|
983
|
+
}, className: "w-full text-left px-3 py-2 text-sm text-df-text-primary hover:bg-df-elevated rounded" }, "Add NPC Node"),
|
|
984
|
+
React.createElement("button", { onClick: () => {
|
|
985
|
+
handleAddNode('player', contextMenu.graphX, contextMenu.graphY);
|
|
986
|
+
}, className: "w-full text-left px-3 py-2 text-sm text-df-text-primary hover:bg-df-elevated rounded" }, "Add Player Node"),
|
|
987
|
+
React.createElement("button", { onClick: () => {
|
|
988
|
+
handleAddNode('conditional', contextMenu.graphX, contextMenu.graphY);
|
|
989
|
+
}, className: "w-full text-left px-3 py-2 text-sm text-df-text-primary hover:bg-df-elevated rounded" }, "Add Conditional Node"),
|
|
990
|
+
React.createElement("button", { onClick: () => setContextMenu(null), className: "w-full text-left px-3 py-2 text-sm text-df-text-secondary hover:bg-df-elevated rounded" }, "Cancel")))),
|
|
991
|
+
edgeDropMenu && (React.createElement("div", { className: "fixed z-50", style: { left: edgeDropMenu.x, top: edgeDropMenu.y } },
|
|
992
|
+
React.createElement("div", { className: "bg-df-sidebar-bg border border-df-sidebar-border rounded-lg shadow-lg p-1 min-w-[150px]" },
|
|
993
|
+
React.createElement("div", { className: "px-3 py-1 text-[10px] text-df-text-secondary uppercase border-b border-df-sidebar-border" }, "Create Node"),
|
|
994
|
+
React.createElement("button", { onClick: () => {
|
|
995
|
+
handleAddNode('npc', edgeDropMenu.graphX, edgeDropMenu.graphY, {
|
|
996
|
+
fromNodeId: edgeDropMenu.fromNodeId,
|
|
997
|
+
fromChoiceIdx: edgeDropMenu.fromChoiceIdx,
|
|
998
|
+
fromBlockIdx: edgeDropMenu.fromBlockIdx,
|
|
999
|
+
sourceHandle: edgeDropMenu.sourceHandle,
|
|
1000
|
+
});
|
|
1001
|
+
}, className: "w-full text-left px-3 py-2 text-sm text-df-text-primary hover:bg-df-elevated rounded" }, "Add NPC Node"),
|
|
1002
|
+
React.createElement("button", { onClick: () => {
|
|
1003
|
+
handleAddNode('player', edgeDropMenu.graphX, edgeDropMenu.graphY, {
|
|
1004
|
+
fromNodeId: edgeDropMenu.fromNodeId,
|
|
1005
|
+
fromChoiceIdx: edgeDropMenu.fromChoiceIdx,
|
|
1006
|
+
fromBlockIdx: edgeDropMenu.fromBlockIdx,
|
|
1007
|
+
sourceHandle: edgeDropMenu.sourceHandle,
|
|
1008
|
+
});
|
|
1009
|
+
}, className: "w-full text-left px-3 py-2 text-sm text-df-text-primary hover:bg-df-elevated rounded" }, "Add Player Node"),
|
|
1010
|
+
React.createElement("button", { onClick: () => {
|
|
1011
|
+
handleAddNode('conditional', edgeDropMenu.graphX, edgeDropMenu.graphY, {
|
|
1012
|
+
fromNodeId: edgeDropMenu.fromNodeId,
|
|
1013
|
+
fromChoiceIdx: edgeDropMenu.fromChoiceIdx,
|
|
1014
|
+
fromBlockIdx: edgeDropMenu.fromBlockIdx,
|
|
1015
|
+
sourceHandle: edgeDropMenu.sourceHandle,
|
|
1016
|
+
});
|
|
1017
|
+
}, className: "w-full text-left px-3 py-2 text-sm text-df-text-primary hover:bg-df-elevated rounded" }, "Add Conditional Node"),
|
|
1018
|
+
React.createElement("button", { onClick: () => {
|
|
1019
|
+
setEdgeDropMenu(null);
|
|
1020
|
+
connectingRef.current = null;
|
|
1021
|
+
}, className: "w-full text-left px-3 py-2 text-sm text-df-text-secondary hover:bg-df-elevated rounded" }, "Cancel")))),
|
|
1022
|
+
edgeContextMenu && (React.createElement("div", { className: "fixed z-50", style: { left: edgeContextMenu.x, top: edgeContextMenu.y } },
|
|
1023
|
+
React.createElement("div", { className: "bg-df-sidebar-bg border border-df-sidebar-border rounded-lg shadow-lg p-1 min-w-[180px]" },
|
|
1024
|
+
React.createElement("div", { className: "px-3 py-1 text-[10px] text-df-text-secondary uppercase border-b border-df-sidebar-border" }, "Insert Node"),
|
|
1025
|
+
React.createElement("button", { onClick: () => {
|
|
1026
|
+
handleInsertNode('npc', edgeContextMenu.edgeId, edgeContextMenu.graphX, edgeContextMenu.graphY);
|
|
1027
|
+
}, className: "w-full text-left px-3 py-2 text-sm text-df-text-primary hover:bg-df-elevated rounded" }, "Insert NPC Node"),
|
|
1028
|
+
React.createElement("button", { onClick: () => {
|
|
1029
|
+
handleInsertNode('player', edgeContextMenu.edgeId, edgeContextMenu.graphX, edgeContextMenu.graphY);
|
|
1030
|
+
}, className: "w-full text-left px-3 py-2 text-sm text-df-text-primary hover:bg-df-elevated rounded" }, "Insert Player Node"),
|
|
1031
|
+
React.createElement("button", { onClick: () => {
|
|
1032
|
+
handleInsertNode('conditional', edgeContextMenu.edgeId, edgeContextMenu.graphX, edgeContextMenu.graphY);
|
|
1033
|
+
}, className: "w-full text-left px-3 py-2 text-sm text-df-text-primary hover:bg-df-elevated rounded" }, "Insert Conditional Node"),
|
|
1034
|
+
React.createElement("button", { onClick: () => setEdgeContextMenu(null), className: "w-full text-left px-3 py-2 text-sm text-df-text-secondary hover:bg-df-elevated rounded" }, "Cancel")))),
|
|
1035
|
+
nodeContextMenu && (React.createElement("div", { className: "fixed z-50", style: { left: nodeContextMenu.x, top: nodeContextMenu.y } },
|
|
1036
|
+
React.createElement("div", { className: "bg-df-elevated border border-df-player-border rounded-lg shadow-xl py-1 min-w-[180px]" },
|
|
1037
|
+
(() => {
|
|
1038
|
+
const node = dialogue.nodes[nodeContextMenu.nodeId];
|
|
1039
|
+
if (!node)
|
|
1040
|
+
return null;
|
|
1041
|
+
return (React.createElement(React.Fragment, null,
|
|
1042
|
+
React.createElement("div", { className: "px-3 py-1 text-[10px] text-df-text-secondary uppercase border-b border-df-control-border" }, node.id),
|
|
1043
|
+
React.createElement("button", { onClick: () => {
|
|
1044
|
+
setSelectedNodeId(nodeContextMenu.nodeId);
|
|
1045
|
+
setNodeContextMenu(null);
|
|
1046
|
+
}, className: "w-full px-4 py-2 text-sm text-left text-df-text-primary hover:bg-df-control-hover flex items-center gap-2" },
|
|
1047
|
+
React.createElement(Edit3, { size: 14, className: "text-df-npc-selected" }),
|
|
1048
|
+
" Edit Node"),
|
|
1049
|
+
node.type === 'player' && (React.createElement("button", { onClick: () => {
|
|
1050
|
+
handleAddChoice(nodeContextMenu.nodeId);
|
|
1051
|
+
setNodeContextMenu(null);
|
|
1052
|
+
}, className: "w-full px-4 py-2 text-sm text-left text-df-text-primary hover:bg-df-control-hover flex items-center gap-2" },
|
|
1053
|
+
React.createElement(Plus, { size: 14, className: "text-df-player-selected" }),
|
|
1054
|
+
" Add Choice")),
|
|
1055
|
+
node.type === 'npc' && !node.conditionalBlocks && (React.createElement("button", { onClick: () => {
|
|
1056
|
+
handleUpdateNode(nodeContextMenu.nodeId, {
|
|
1057
|
+
conditionalBlocks: [{
|
|
1058
|
+
id: `block_${Date.now()}`,
|
|
1059
|
+
type: 'if',
|
|
1060
|
+
condition: [],
|
|
1061
|
+
content: node.content,
|
|
1062
|
+
speaker: node.speaker
|
|
1063
|
+
}]
|
|
1064
|
+
});
|
|
1065
|
+
setSelectedNodeId(nodeContextMenu.nodeId);
|
|
1066
|
+
setNodeContextMenu(null);
|
|
1067
|
+
}, className: "w-full px-4 py-2 text-sm text-left text-df-text-primary hover:bg-df-control-hover flex items-center gap-2" },
|
|
1068
|
+
React.createElement(Plus, { size: 14, className: "text-df-conditional-border" }),
|
|
1069
|
+
" Add Conditionals")),
|
|
1070
|
+
node.id !== dialogue.startNodeId && (React.createElement("button", { onClick: () => {
|
|
1071
|
+
handleDeleteNode(nodeContextMenu.nodeId);
|
|
1072
|
+
setNodeContextMenu(null);
|
|
1073
|
+
}, className: "w-full px-4 py-2 text-sm text-left text-df-error hover:bg-df-control-hover flex items-center gap-2" },
|
|
1074
|
+
React.createElement(Trash2, { size: 14 }),
|
|
1075
|
+
" Delete"))));
|
|
1076
|
+
})(),
|
|
1077
|
+
React.createElement("button", { onClick: () => setNodeContextMenu(null), className: "w-full px-4 py-1.5 text-xs text-df-text-secondary hover:text-df-text-primary border-t border-df-control-border mt-1" }, "Cancel")))))),
|
|
1078
|
+
selectedNode && (React.createElement(NodeEditor, { node: selectedNode, dialogue: dialogue, onUpdate: (updates) => handleUpdateNode(selectedNode.id, updates), onFocusNode: (nodeId) => {
|
|
1079
|
+
const targetNode = nodes.find(n => n.id === nodeId);
|
|
1080
|
+
if (targetNode && reactFlowInstance) {
|
|
1081
|
+
// Set selectedNodeId first so NodeEditor updates
|
|
1082
|
+
setSelectedNodeId(nodeId);
|
|
1083
|
+
// Update nodes using React Flow instance to ensure proper selection
|
|
1084
|
+
const allNodes = reactFlowInstance.getNodes();
|
|
1085
|
+
const updatedNodes = allNodes.map((n) => ({
|
|
1086
|
+
...n,
|
|
1087
|
+
selected: n.id === nodeId
|
|
1088
|
+
}));
|
|
1089
|
+
reactFlowInstance.setNodes(updatedNodes);
|
|
1090
|
+
// Also update local state to keep in sync
|
|
1091
|
+
setNodes(updatedNodes);
|
|
1092
|
+
// Focus on the target node with animation
|
|
1093
|
+
setTimeout(() => {
|
|
1094
|
+
reactFlowInstance.fitView({
|
|
1095
|
+
nodes: [{ id: nodeId }],
|
|
1096
|
+
padding: 0.2,
|
|
1097
|
+
duration: 500,
|
|
1098
|
+
minZoom: 0.5,
|
|
1099
|
+
maxZoom: 2
|
|
1100
|
+
});
|
|
1101
|
+
}, 0);
|
|
1102
|
+
}
|
|
1103
|
+
}, onDelete: () => handleDeleteNode(selectedNode.id), onAddChoice: () => handleAddChoice(selectedNode.id), onUpdateChoice: (idx, updates) => handleUpdateChoice(selectedNode.id, idx, updates), onRemoveChoice: (idx) => handleRemoveChoice(selectedNode.id, idx), onClose: () => setSelectedNodeId(null), flagSchema: flagSchema })))),
|
|
1104
|
+
viewMode === 'yarn' && (React.createElement(YarnView, { dialogue: dialogue, onExport: () => {
|
|
1105
|
+
const yarn = exportToYarn(dialogue);
|
|
1106
|
+
if (onExportYarn) {
|
|
1107
|
+
onExportYarn(yarn);
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
// Default: download file
|
|
1111
|
+
const blob = new Blob([yarn], { type: 'text/plain' });
|
|
1112
|
+
const url = URL.createObjectURL(blob);
|
|
1113
|
+
const a = document.createElement('a');
|
|
1114
|
+
a.href = url;
|
|
1115
|
+
a.download = `${dialogue.title.replace(/\s+/g, '_')}.yarn`;
|
|
1116
|
+
a.click();
|
|
1117
|
+
URL.revokeObjectURL(url);
|
|
1118
|
+
}
|
|
1119
|
+
}, onImport: (yarn) => {
|
|
1120
|
+
try {
|
|
1121
|
+
const importedDialogue = importFromYarn(yarn, dialogue.title);
|
|
1122
|
+
onChange(importedDialogue);
|
|
1123
|
+
}
|
|
1124
|
+
catch (err) {
|
|
1125
|
+
console.error('Failed to import Yarn:', err);
|
|
1126
|
+
alert('Failed to import Yarn file. Please check the format.');
|
|
1127
|
+
}
|
|
1128
|
+
} })),
|
|
1129
|
+
viewMode === 'play' && (React.createElement(PlayView, { dialogue: dialogue, flagSchema: flagSchema }))));
|
|
1130
|
+
}
|
|
1131
|
+
export function DialogueEditorV2(props) {
|
|
1132
|
+
return (React.createElement(ReactFlowProvider, null,
|
|
1133
|
+
React.createElement(DialogueEditorV2Internal, { ...props })));
|
|
1134
|
+
}
|