@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.
Files changed (241) hide show
  1. package/README.md +233 -0
  2. package/bin/dialogue-forge.js +78 -0
  3. package/demo/app/layout.tsx +36 -0
  4. package/demo/app/page.tsx +440 -0
  5. package/demo/components/ThemeSwitcher.tsx +611 -0
  6. package/demo/next.config.mjs +7 -0
  7. package/demo/package.json +29 -0
  8. package/demo/postcss.config.mjs +7 -0
  9. package/demo/public/logo.svg +1 -0
  10. package/demo/styles/globals.css +19 -0
  11. package/demo/tailwind.config.ts +90 -0
  12. package/demo/tsconfig.json +42 -0
  13. package/dist/components/ChoiceEdgeV2.d.ts +3 -0
  14. package/dist/components/ChoiceEdgeV2.js +103 -0
  15. package/dist/components/CodeBlock.d.ts +8 -0
  16. package/dist/components/CodeBlock.js +24 -0
  17. package/dist/components/ConditionAutocomplete.d.ts +14 -0
  18. package/dist/components/ConditionAutocomplete.js +284 -0
  19. package/dist/components/ConditionalNodeV2.d.ts +16 -0
  20. package/dist/components/ConditionalNodeV2.js +147 -0
  21. package/dist/components/DialogueEditorV2.d.ts +22 -0
  22. package/dist/components/DialogueEditorV2.js +1170 -0
  23. package/dist/components/EdgeIcon.d.ts +8 -0
  24. package/dist/components/EdgeIcon.js +13 -0
  25. package/dist/components/ExampleLoader.d.ts +11 -0
  26. package/dist/components/ExampleLoader.js +52 -0
  27. package/dist/components/ExampleLoaderButton.d.ts +15 -0
  28. package/dist/components/ExampleLoaderButton.js +102 -0
  29. package/dist/components/FlagManager.d.ts +11 -0
  30. package/dist/components/FlagManager.js +282 -0
  31. package/dist/components/FlagSelector.d.ts +11 -0
  32. package/dist/components/FlagSelector.js +235 -0
  33. package/dist/components/GuidePanel.d.ts +7 -0
  34. package/dist/components/GuidePanel.js +1176 -0
  35. package/dist/components/Minimap.d.ts +16 -0
  36. package/dist/components/Minimap.js +93 -0
  37. package/dist/components/NPCEdgeV2.d.ts +3 -0
  38. package/dist/components/NPCEdgeV2.js +104 -0
  39. package/dist/components/NPCNodeV2.d.ts +26 -0
  40. package/dist/components/NPCNodeV2.js +86 -0
  41. package/dist/components/NodeEditor.d.ts +18 -0
  42. package/dist/components/NodeEditor.js +1025 -0
  43. package/dist/components/PlayView.d.ts +12 -0
  44. package/dist/components/PlayView.js +307 -0
  45. package/dist/components/PlayerNodeV2.d.ts +16 -0
  46. package/dist/components/PlayerNodeV2.js +139 -0
  47. package/dist/components/ReactFlowPOC.d.ts +61 -0
  48. package/dist/components/ReactFlowPOC.js +312 -0
  49. package/dist/components/ScenePlayer.d.ts +18 -0
  50. package/dist/components/ScenePlayer.js +196 -0
  51. package/dist/components/YarnView.d.ts +9 -0
  52. package/dist/components/YarnView.js +45 -0
  53. package/dist/components/ZoomControls.d.ts +11 -0
  54. package/dist/components/ZoomControls.js +34 -0
  55. package/dist/esm/components/ChoiceEdgeV2.d.ts +3 -0
  56. package/dist/esm/components/ChoiceEdgeV2.js +67 -0
  57. package/dist/esm/components/CodeBlock.d.ts +8 -0
  58. package/dist/esm/components/CodeBlock.js +18 -0
  59. package/dist/esm/components/ConditionAutocomplete.d.ts +14 -0
  60. package/dist/esm/components/ConditionAutocomplete.js +248 -0
  61. package/dist/esm/components/ConditionalNodeV2.d.ts +16 -0
  62. package/dist/esm/components/ConditionalNodeV2.js +111 -0
  63. package/dist/esm/components/DialogueEditorV2.d.ts +22 -0
  64. package/dist/esm/components/DialogueEditorV2.js +1134 -0
  65. package/dist/esm/components/EdgeIcon.d.ts +8 -0
  66. package/dist/esm/components/EdgeIcon.js +7 -0
  67. package/dist/esm/components/ExampleLoader.d.ts +11 -0
  68. package/dist/esm/components/ExampleLoader.js +46 -0
  69. package/dist/esm/components/ExampleLoaderButton.d.ts +15 -0
  70. package/dist/esm/components/ExampleLoaderButton.js +66 -0
  71. package/dist/esm/components/FlagManager.d.ts +11 -0
  72. package/dist/esm/components/FlagManager.js +246 -0
  73. package/dist/esm/components/FlagSelector.d.ts +11 -0
  74. package/dist/esm/components/FlagSelector.js +199 -0
  75. package/dist/esm/components/GuidePanel.d.ts +7 -0
  76. package/dist/esm/components/GuidePanel.js +1140 -0
  77. package/dist/esm/components/Minimap.d.ts +16 -0
  78. package/dist/esm/components/Minimap.js +57 -0
  79. package/dist/esm/components/NPCEdgeV2.d.ts +3 -0
  80. package/dist/esm/components/NPCEdgeV2.js +68 -0
  81. package/dist/esm/components/NPCNodeV2.d.ts +26 -0
  82. package/dist/esm/components/NPCNodeV2.js +80 -0
  83. package/dist/esm/components/NodeEditor.d.ts +18 -0
  84. package/dist/esm/components/NodeEditor.js +989 -0
  85. package/dist/esm/components/PlayView.d.ts +12 -0
  86. package/dist/esm/components/PlayView.js +271 -0
  87. package/dist/esm/components/PlayerNodeV2.d.ts +16 -0
  88. package/dist/esm/components/PlayerNodeV2.js +103 -0
  89. package/dist/esm/components/ReactFlowPOC.d.ts +61 -0
  90. package/dist/esm/components/ReactFlowPOC.js +275 -0
  91. package/dist/esm/components/ScenePlayer.d.ts +18 -0
  92. package/dist/esm/components/ScenePlayer.js +160 -0
  93. package/dist/esm/components/YarnView.d.ts +9 -0
  94. package/dist/esm/components/YarnView.js +39 -0
  95. package/dist/esm/components/ZoomControls.d.ts +11 -0
  96. package/dist/esm/components/ZoomControls.js +28 -0
  97. package/dist/esm/examples/example-loader.d.ts +29 -0
  98. package/dist/esm/examples/example-loader.js +103 -0
  99. package/dist/esm/examples/examples-registry.d.ts +38 -0
  100. package/dist/esm/examples/examples-registry.js +153 -0
  101. package/dist/esm/examples/index.d.ts +26 -0
  102. package/dist/esm/examples/index.js +50 -0
  103. package/dist/esm/examples/legacy-examples.d.ts +9 -0
  104. package/dist/esm/examples/legacy-examples.js +814 -0
  105. package/dist/esm/examples/yarn-examples.d.ts +35 -0
  106. package/dist/esm/examples/yarn-examples.js +181 -0
  107. package/dist/esm/index.d.ts +21 -0
  108. package/dist/esm/index.js +26 -0
  109. package/dist/esm/lib/flag-manager.d.ts +21 -0
  110. package/dist/esm/lib/flag-manager.js +93 -0
  111. package/dist/esm/lib/yarn-converter/__tests__/round-trip.test.d.ts +1 -0
  112. package/dist/esm/lib/yarn-converter/__tests__/round-trip.test.js +169 -0
  113. package/dist/esm/lib/yarn-converter.d.ts +17 -0
  114. package/dist/esm/lib/yarn-converter.js +521 -0
  115. package/dist/esm/lib/yarn-runner/__tests__/condition-evaluator.test.d.ts +1 -0
  116. package/dist/esm/lib/yarn-runner/__tests__/condition-evaluator.test.js +171 -0
  117. package/dist/esm/lib/yarn-runner/__tests__/node-processor.test.d.ts +1 -0
  118. package/dist/esm/lib/yarn-runner/__tests__/node-processor.test.js +237 -0
  119. package/dist/esm/lib/yarn-runner/__tests__/variable-manager.test.d.ts +1 -0
  120. package/dist/esm/lib/yarn-runner/__tests__/variable-manager.test.js +106 -0
  121. package/dist/esm/lib/yarn-runner/condition-evaluator.d.ts +12 -0
  122. package/dist/esm/lib/yarn-runner/condition-evaluator.js +56 -0
  123. package/dist/esm/lib/yarn-runner/index.d.ts +12 -0
  124. package/dist/esm/lib/yarn-runner/index.js +11 -0
  125. package/dist/esm/lib/yarn-runner/node-processor.d.ts +18 -0
  126. package/dist/esm/lib/yarn-runner/node-processor.js +129 -0
  127. package/dist/esm/lib/yarn-runner/variable-manager.d.ts +51 -0
  128. package/dist/esm/lib/yarn-runner/variable-manager.js +120 -0
  129. package/dist/esm/lib/yarn-runner/variable-operations.d.ts +16 -0
  130. package/dist/esm/lib/yarn-runner/variable-operations.js +88 -0
  131. package/dist/esm/types/conditionals.d.ts +29 -0
  132. package/dist/esm/types/conditionals.js +1 -0
  133. package/dist/esm/types/constants.d.ts +59 -0
  134. package/dist/esm/types/constants.js +55 -0
  135. package/dist/esm/types/flags.d.ts +49 -0
  136. package/dist/esm/types/flags.js +49 -0
  137. package/dist/esm/types/game-state.d.ts +62 -0
  138. package/dist/esm/types/game-state.js +6 -0
  139. package/dist/esm/types/index.d.ts +77 -0
  140. package/dist/esm/types/index.js +1 -0
  141. package/dist/esm/utils/constants.d.ts +5 -0
  142. package/dist/esm/utils/constants.js +5 -0
  143. package/dist/esm/utils/feature-flags.d.ts +11 -0
  144. package/dist/esm/utils/feature-flags.js +11 -0
  145. package/dist/esm/utils/game-state-flattener.d.ts +41 -0
  146. package/dist/esm/utils/game-state-flattener.js +135 -0
  147. package/dist/esm/utils/layout/collision.d.ts +27 -0
  148. package/dist/esm/utils/layout/collision.js +74 -0
  149. package/dist/esm/utils/layout/index.d.ts +82 -0
  150. package/dist/esm/utils/layout/index.js +98 -0
  151. package/dist/esm/utils/layout/registry.d.ts +91 -0
  152. package/dist/esm/utils/layout/registry.js +148 -0
  153. package/dist/esm/utils/layout/strategies/dagre.d.ts +19 -0
  154. package/dist/esm/utils/layout/strategies/dagre.js +182 -0
  155. package/dist/esm/utils/layout/strategies/force.d.ts +21 -0
  156. package/dist/esm/utils/layout/strategies/force.js +178 -0
  157. package/dist/esm/utils/layout/strategies/grid.d.ts +17 -0
  158. package/dist/esm/utils/layout/strategies/grid.js +91 -0
  159. package/dist/esm/utils/layout/strategies/index.d.ts +8 -0
  160. package/dist/esm/utils/layout/strategies/index.js +8 -0
  161. package/dist/esm/utils/layout/types.d.ts +100 -0
  162. package/dist/esm/utils/layout/types.js +7 -0
  163. package/dist/esm/utils/layout.d.ts +9 -0
  164. package/dist/esm/utils/layout.js +17 -0
  165. package/dist/esm/utils/node-helpers.d.ts +7 -0
  166. package/dist/esm/utils/node-helpers.js +94 -0
  167. package/dist/esm/utils/reactflow-converter.d.ts +42 -0
  168. package/dist/esm/utils/reactflow-converter.js +217 -0
  169. package/dist/examples/example-loader.d.ts +29 -0
  170. package/dist/examples/example-loader.js +109 -0
  171. package/dist/examples/examples-registry.d.ts +38 -0
  172. package/dist/examples/examples-registry.js +160 -0
  173. package/dist/examples/index.d.ts +26 -0
  174. package/dist/examples/index.js +63 -0
  175. package/dist/examples/legacy-examples.d.ts +9 -0
  176. package/dist/examples/legacy-examples.js +817 -0
  177. package/dist/examples/yarn-examples.d.ts +35 -0
  178. package/dist/examples/yarn-examples.js +189 -0
  179. package/dist/index.d.ts +21 -0
  180. package/dist/index.js +66 -0
  181. package/dist/lib/flag-manager.d.ts +21 -0
  182. package/dist/lib/flag-manager.js +99 -0
  183. package/dist/lib/yarn-converter/__tests__/round-trip.test.d.ts +1 -0
  184. package/dist/lib/yarn-converter/__tests__/round-trip.test.js +171 -0
  185. package/dist/lib/yarn-converter.d.ts +17 -0
  186. package/dist/lib/yarn-converter.js +525 -0
  187. package/dist/lib/yarn-runner/__tests__/condition-evaluator.test.d.ts +1 -0
  188. package/dist/lib/yarn-runner/__tests__/condition-evaluator.test.js +173 -0
  189. package/dist/lib/yarn-runner/__tests__/node-processor.test.d.ts +1 -0
  190. package/dist/lib/yarn-runner/__tests__/node-processor.test.js +239 -0
  191. package/dist/lib/yarn-runner/__tests__/variable-manager.test.d.ts +1 -0
  192. package/dist/lib/yarn-runner/__tests__/variable-manager.test.js +108 -0
  193. package/dist/lib/yarn-runner/condition-evaluator.d.ts +12 -0
  194. package/dist/lib/yarn-runner/condition-evaluator.js +60 -0
  195. package/dist/lib/yarn-runner/index.d.ts +12 -0
  196. package/dist/lib/yarn-runner/index.js +21 -0
  197. package/dist/lib/yarn-runner/node-processor.d.ts +18 -0
  198. package/dist/lib/yarn-runner/node-processor.js +133 -0
  199. package/dist/lib/yarn-runner/variable-manager.d.ts +51 -0
  200. package/dist/lib/yarn-runner/variable-manager.js +124 -0
  201. package/dist/lib/yarn-runner/variable-operations.d.ts +16 -0
  202. package/dist/lib/yarn-runner/variable-operations.js +92 -0
  203. package/dist/types/conditionals.d.ts +29 -0
  204. package/dist/types/conditionals.js +2 -0
  205. package/dist/types/constants.d.ts +59 -0
  206. package/dist/types/constants.js +58 -0
  207. package/dist/types/flags.d.ts +49 -0
  208. package/dist/types/flags.js +52 -0
  209. package/dist/types/game-state.d.ts +62 -0
  210. package/dist/types/game-state.js +7 -0
  211. package/dist/types/index.d.ts +77 -0
  212. package/dist/types/index.js +2 -0
  213. package/dist/utils/constants.d.ts +5 -0
  214. package/dist/utils/constants.js +8 -0
  215. package/dist/utils/feature-flags.d.ts +11 -0
  216. package/dist/utils/feature-flags.js +14 -0
  217. package/dist/utils/game-state-flattener.d.ts +41 -0
  218. package/dist/utils/game-state-flattener.js +140 -0
  219. package/dist/utils/layout/collision.d.ts +27 -0
  220. package/dist/utils/layout/collision.js +77 -0
  221. package/dist/utils/layout/index.d.ts +82 -0
  222. package/dist/utils/layout/index.js +109 -0
  223. package/dist/utils/layout/registry.d.ts +91 -0
  224. package/dist/utils/layout/registry.js +151 -0
  225. package/dist/utils/layout/strategies/dagre.d.ts +19 -0
  226. package/dist/utils/layout/strategies/dagre.js +189 -0
  227. package/dist/utils/layout/strategies/force.d.ts +21 -0
  228. package/dist/utils/layout/strategies/force.js +182 -0
  229. package/dist/utils/layout/strategies/grid.d.ts +17 -0
  230. package/dist/utils/layout/strategies/grid.js +95 -0
  231. package/dist/utils/layout/strategies/index.d.ts +8 -0
  232. package/dist/utils/layout/strategies/index.js +14 -0
  233. package/dist/utils/layout/types.d.ts +100 -0
  234. package/dist/utils/layout/types.js +8 -0
  235. package/dist/utils/layout.d.ts +9 -0
  236. package/dist/utils/layout.js +25 -0
  237. package/dist/utils/node-helpers.d.ts +7 -0
  238. package/dist/utils/node-helpers.js +101 -0
  239. package/dist/utils/reactflow-converter.d.ts +42 -0
  240. package/dist/utils/reactflow-converter.js +223 -0
  241. 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
+ }