@stonecrop/node-editor 0.13.11 → 0.13.14

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.
@@ -1,12 +1,10 @@
1
1
  import { type Elements, type Element, type XYPosition, Position } from '@vue-flow/core';
2
- import type { AnyStateNodeDefinition, AnyStateNodeConfig } from 'xstate';
2
+ import type { AnyStateNodeConfig } from 'xstate';
3
3
  /**
4
4
  * Editor states
5
5
  * @public
6
6
  */
7
- export type EditorStates = {
8
- [key: string]: AnyStateNodeDefinition | AnyStateNodeConfig['states'];
9
- };
7
+ export type EditorStates = Record<string, AnyStateNodeConfig>;
10
8
  /**
11
9
  * Flow elements
12
10
  * @public
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/types/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAE,KAAK,OAAO,EAAE,KAAK,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACvF,OAAO,KAAK,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,QAAQ,CAAA;AAExE;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG;IAC1B,CAAC,GAAG,EAAE,MAAM,GAAG,sBAAsB,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAA;CACpE,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG,QAAQ,CAClC;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,EAC3C;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,CAC3C,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,CAChC;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,EAC3C;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,CAC3C,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,MAAM,GAAG;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG;QACd,QAAQ,CAAC,EAAE,UAAU,CAAA;QACrB,cAAc,CAAC,EAAE,QAAQ,CAAA;QACzB,cAAc,CAAC,EAAE,QAAQ,CAAA;KACzB,CAAA;CACD,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/types/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAE,KAAK,OAAO,EAAE,KAAK,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACvF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,QAAQ,CAAA;AAEhD;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAA;AAE7D;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG,QAAQ,CAClC;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,EAC3C;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,CAC3C,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,CAChC;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,EAC3C;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,CAC3C,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,MAAM,GAAG;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG;QACd,QAAQ,CAAC,EAAE,UAAU,CAAA;QACrB,cAAc,CAAC,EAAE,QAAQ,CAAA;QACzB,cAAc,CAAC,EAAE,QAAQ,CAAA;KACzB,CAAA;CACD,CAAA"}
@@ -0,0 +1,7 @@
1
+ import type { EditorStates, FlowElements, Layout } from '../types';
2
+ export declare function statesToFlowElements(states: EditorStates, layout?: Layout): FlowElements;
3
+ export declare function flowElementsToStates(nextElements: FlowElements): {
4
+ states: EditorStates;
5
+ layout: Layout;
6
+ };
7
+ //# sourceMappingURL=stateTransforms.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stateTransforms.d.ts","sourceRoot":"","sources":["../../../src/utils/stateTransforms.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAElE,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,YAAY,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,YAAY,CA2DxF;AAED,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,YAAY,GAAG;IAAE,MAAM,EAAE,YAAY,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAmDzG"}
@@ -0,0 +1,104 @@
1
+ import { Position } from '@vue-flow/core';
2
+ export function statesToFlowElements(states, layout) {
3
+ const hasInputs = {};
4
+ const stateElements = [];
5
+ const stateHash = {};
6
+ let index = 0;
7
+ for (const [key, value] of Object.entries(states)) {
8
+ const el = {
9
+ id: key,
10
+ label: key,
11
+ position: layout?.[key]?.position ?? { x: 200 * index, y: 100 },
12
+ targetPosition: layout?.[key]?.targetPosition ?? Position.Left,
13
+ sourcePosition: layout?.[key]?.sourcePosition ?? Position.Right,
14
+ };
15
+ if (value.type === 'final') {
16
+ el.type = 'output';
17
+ el.class = 'default-output-node';
18
+ }
19
+ stateHash[key] = el;
20
+ index++;
21
+ }
22
+ for (const [key, value] of Object.entries(states)) {
23
+ if (value.on) {
24
+ for (const [edgeKey, edgeValue] of Object.entries(value.on)) {
25
+ const target = typeof edgeValue === 'string'
26
+ ? edgeValue
27
+ : edgeValue != null &&
28
+ typeof edgeValue === 'object' &&
29
+ 'target' in edgeValue &&
30
+ typeof edgeValue.target === 'string'
31
+ ? edgeValue.target
32
+ : '';
33
+ stateElements.push({
34
+ id: `${key}-${target}`,
35
+ source: key,
36
+ target,
37
+ label: edgeKey,
38
+ animated: true,
39
+ type: 'smoothstep',
40
+ interactionWidth: 40,
41
+ });
42
+ hasInputs[target] = true;
43
+ }
44
+ }
45
+ }
46
+ for (const [key] of Object.entries(stateHash)) {
47
+ if (!hasInputs[key]) {
48
+ stateHash[key].type = 'input';
49
+ stateHash[key].class = 'default-input-node';
50
+ }
51
+ stateElements.push(stateHash[key]);
52
+ }
53
+ return stateElements;
54
+ }
55
+ export function flowElementsToStates(nextElements) {
56
+ const transitionsBySource = {};
57
+ const idToLabel = {};
58
+ const nodeTypeByLabel = {};
59
+ const nextLayout = {};
60
+ // First pass: build id→label map from nodes so edge targets resolve correctly
61
+ for (const el of nextElements) {
62
+ if (!('source' in el)) {
63
+ idToLabel[el.id] = typeof el.label === 'string' ? el.label : el.id;
64
+ }
65
+ }
66
+ // Second pass: collect node types, layout positions, and edge transitions
67
+ for (const el of nextElements) {
68
+ if ('source' in el && 'target' in el) {
69
+ const edgeLabel = typeof el.label === 'string' ? el.label : el.id;
70
+ const sourceLabel = idToLabel[el.source] || el.source;
71
+ const targetLabel = idToLabel[el.target] || el.target;
72
+ transitionsBySource[sourceLabel] = transitionsBySource[sourceLabel] ?? {};
73
+ transitionsBySource[sourceLabel][edgeLabel] = { target: targetLabel };
74
+ }
75
+ else {
76
+ const nodeLabel = typeof el.label === 'string' ? el.label : el.id;
77
+ nodeTypeByLabel[nodeLabel] = el.type;
78
+ if (el.position) {
79
+ nextLayout[nodeLabel] = {
80
+ position: el.position,
81
+ ...(el.targetPosition !== undefined && { targetPosition: el.targetPosition }),
82
+ ...(el.sourcePosition !== undefined && { sourcePosition: el.sourcePosition }),
83
+ };
84
+ }
85
+ }
86
+ }
87
+ // Build states from node types and collected transitions
88
+ const nextStates = {};
89
+ for (const [label, nodeType] of Object.entries(nodeTypeByLabel)) {
90
+ if (nodeType === 'output') {
91
+ nextStates[label] = { type: 'final' };
92
+ }
93
+ else {
94
+ nextStates[label] = { on: transitionsBySource[label] ?? {} };
95
+ }
96
+ }
97
+ // Capture any transition sources that had no corresponding node entry
98
+ for (const [sourceLabel, on] of Object.entries(transitionsBySource)) {
99
+ if (!nextStates[sourceLabel]) {
100
+ nextStates[sourceLabel] = { on };
101
+ }
102
+ }
103
+ return { states: nextStates, layout: nextLayout };
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonecrop/node-editor",
3
- "version": "0.13.11",
3
+ "version": "0.13.14",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "author": {
@@ -43,10 +43,12 @@
43
43
  "@microsoft/api-documenter": "^7.30.5",
44
44
  "@rushstack/heft": "^1.2.17",
45
45
  "@vitejs/plugin-vue": "^6.0.6",
46
+ "@vitest/coverage-istanbul": "^4.1.5",
46
47
  "oxlint": "1.67.0",
47
48
  "oxlint-tsgolint": "0.23.0",
48
49
  "typescript": "^6.0.3",
49
50
  "vite": "^7.3.2",
51
+ "vitest": "^4.1.5",
50
52
  "vue": "^3.5.33",
51
53
  "vue-router": "^5.0.6",
52
54
  "stonecrop-rig": "0.7.0"
@@ -65,6 +67,9 @@
65
67
  "docs": "bash ../common/scripts/run-docs.sh node_editor",
66
68
  "lint": "oxlint",
67
69
  "lint:fix": "oxlint --fix",
68
- "preview": "vite preview"
70
+ "preview": "vite preview",
71
+ "test": "vitest run --coverage.enabled false",
72
+ "test:watch": "vitest watch",
73
+ "test:coverage": "vitest run --coverage"
69
74
  }
70
75
  }
@@ -1,4 +1,6 @@
1
1
  <template>
2
+ <!-- transparent ghost path gives a ~20px click zone around the 2px visible stroke -->
3
+ <path :d="path[0]" fill="none" stroke="transparent" stroke-width="20" class="vue-flow__edge-interaction" />
2
4
  <path :id="id" :style="style" class="vue-flow__edge-path" :d="path[0]" :marker-end="markerEnd" />
3
5
 
4
6
  <EdgeLabelRenderer>
@@ -9,7 +11,8 @@
9
11
  transform: `translate(-50%, -50%) translate(${path[1]}px,${path[2]}px)`,
10
12
  }"
11
13
  class="nodrag nopan editable-edge-label"
12
- @click="labelOnClick()">
14
+ @click="labelOnClick()"
15
+ @contextmenu.prevent="$emit('remove', id)">
13
16
  <div class="vue-flow__edge-label">{{ label }}</div>
14
17
  <div v-if="showInput" class="label-input-wrapper">
15
18
  <input
@@ -28,10 +31,7 @@ import { type EdgeProps, EdgeLabelRenderer, getBezierPath /* useVueFlow */ } fro
28
31
  import { computed, ref, nextTick, useTemplateRef } from 'vue'
29
32
 
30
33
  const props = defineProps<EdgeProps>()
31
- const emit = defineEmits(['change'])
32
-
33
- // TODO: Implement edge removal
34
- // const { removeEdges } = useVueFlow()
34
+ const emit = defineEmits(['change', 'remove'])
35
35
 
36
36
  const inputRef = useTemplateRef<HTMLInputElement>('labelInput')
37
37
  const newLabel = ref<EdgeProps['label']>('')
@@ -38,7 +38,7 @@ const showLabelInput = async () => {
38
38
  newLabel.value = props.label
39
39
  showInput.value = true
40
40
  await nextTick()
41
- inputRef.value.focus()
41
+ inputRef.value?.focus()
42
42
  }
43
43
 
44
44
  const submitNewLabel = () => {
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <div class="node-editor-wrapper" :class="nodeContainerClass" @mouseover="hover = true" @mouseleave="hover = false">
2
+ <div
3
+ class="node-editor-wrapper"
4
+ :class="nodeContainerClass"
5
+ @contextmenu.prevent
6
+ @mouseover="hover = true"
7
+ @mouseleave="hover = false">
3
8
  <div class="chart-controls">
4
9
  <div class="chart-controls-left">
5
10
  <div><b>Selected Node:</b> {{ activeElementKey ? activeElementKey : 'none' }}</div>
@@ -29,13 +34,16 @@
29
34
  :fit-view-on-init="true"
30
35
  @connect="handleConnect"
31
36
  @pane-ready="setInstance"
37
+ @node-click="handleNodeClick"
38
+ @edge-click="handleEdgeClick"
32
39
  @edge-context-menu="handleEdgeContextMenu"
40
+ @node-drag-stop="emitElements"
33
41
  @wheel.prevent="onWheel">
34
42
  <template #node-editable="props">
35
43
  <EditableNode v-bind="props" @change="labelChanged($event, props.id)" />
36
44
  </template>
37
45
  <template #edge-editable="props">
38
- <EditableEdge v-bind="props" @change="labelChanged($event, props.id)" />
46
+ <EditableEdge v-bind="props" @change="labelChanged($event, props.id)" @remove="removeEdge(props.id)" />
39
47
  </template>
40
48
  </VueFlow>
41
49
  </div>
@@ -49,10 +57,11 @@ import {
49
57
  useVueFlow,
50
58
  Connection,
51
59
  Node,
60
+ NodeMouseEvent,
52
61
  EdgeMouseEvent,
53
62
  DefaultEdge,
54
63
  } from '@vue-flow/core'
55
- import { type HTMLAttributes, ref, computed, defineEmits, onBeforeUnmount, onMounted } from 'vue'
64
+ import { type HTMLAttributes, ref, computed, nextTick, onBeforeUnmount, onMounted } from 'vue'
56
65
 
57
66
  import EditableEdge from './EditableEdge.vue'
58
67
  import EditableNode from './EditableNode.vue'
@@ -69,15 +78,9 @@ const vueFlowElements = ref<FlowElements>([])
69
78
  const vueFlowInstance = ref<VueFlowStore>()
70
79
 
71
80
  const activeElementKey = ref('')
72
- const activeElementIndex = computed(() => {
73
- vueFlowElements.value.forEach((element, index) => {
74
- if (element.id === activeElementKey.value) {
75
- return index
76
- }
77
- })
78
-
79
- return -1
80
- })
81
+ const activeElementIndex = computed(() =>
82
+ vueFlowElements.value.findIndex(element => element.id === activeElementKey.value)
83
+ )
81
84
 
82
85
  const elements = computed({
83
86
  get: () => {
@@ -100,15 +103,6 @@ const elements = computed({
100
103
  element.type = 'editable'
101
104
  }
102
105
 
103
- // Add click event to each element
104
- for (const element of items) {
105
- element.events = {
106
- click: () => {
107
- activeElementKey.value = element.id
108
- },
109
- }
110
- }
111
-
112
106
  return items
113
107
  },
114
108
  set: newValue => {
@@ -147,6 +141,7 @@ const shiftOutput = () => {
147
141
  const activeNode = vueFlowElements.value[activeElementIndex.value] as Node
148
142
  if (!activeNode.sourcePosition) return
149
143
  activeNode.sourcePosition = shiftTerminal(activeNode.sourcePosition)
144
+ emitElements()
150
145
  }
151
146
  }
152
147
 
@@ -155,6 +150,7 @@ const shiftInput = () => {
155
150
  const activeNode = vueFlowElements.value[activeElementIndex.value] as Node
156
151
  if (!activeNode.targetPosition) return
157
152
  activeNode.targetPosition = shiftTerminal(activeNode.targetPosition)
153
+ emitElements()
158
154
  }
159
155
  }
160
156
 
@@ -201,17 +197,11 @@ const addNode = () => {
201
197
  hasInput: true,
202
198
  hasOutput: true,
203
199
  },
204
- events: {
205
- click: () => {
206
- activeElementKey.value = nodeId
207
- },
208
- },
209
- // position: { x: Math.random() * vueFlowInstance.value.dimensions.width, y: Math.random() * vueFlowInstance.value.dimensions.height }
210
200
  position: newNodePosition,
211
201
  })
212
202
 
213
203
  if (makeEdge) {
214
- let edgeId = `edge-${id + 1}`
204
+ const edgeId = `edge-${id + 1}`
215
205
  vueFlowElements.value.push({
216
206
  id: edgeId,
217
207
  source: activeElementKey.value,
@@ -219,13 +209,9 @@ const addNode = () => {
219
209
  type: 'editable',
220
210
  label: `EDGE ${id + 1}`,
221
211
  animated: true,
222
- events: {
223
- click: () => {
224
- activeElementKey.value = edgeId
225
- },
226
- },
227
212
  })
228
213
  }
214
+ emitElements()
229
215
  }
230
216
 
231
217
  const labelChanged = (event: DefaultEdge['label'], id: DefaultEdge['id']) => {
@@ -235,9 +221,18 @@ const labelChanged = (event: DefaultEdge['label'], id: DefaultEdge['id']) => {
235
221
  break
236
222
  }
237
223
  }
224
+ emitElements()
238
225
  }
239
226
 
240
- const handleConnect = (event: Connection) => {
227
+ const handleNodeClick = ({ node }: NodeMouseEvent) => {
228
+ activeElementKey.value = node.id
229
+ }
230
+
231
+ const handleEdgeClick = ({ edge }: EdgeMouseEvent) => {
232
+ activeElementKey.value = edge.id
233
+ }
234
+
235
+ const handleConnect = async (event: Connection) => {
241
236
  const id = vueFlowElements.value.length
242
237
  const newEdge = {
243
238
  id: `edge-${id}`,
@@ -247,17 +242,26 @@ const handleConnect = (event: Connection) => {
247
242
  label: `New Edge`,
248
243
  interactionWidth: 400,
249
244
  animated: true,
250
- events: {
251
- click: () => {
252
- activeElementKey.value = newEdge.id
253
- },
254
- },
255
245
  }
256
246
  addEdges([newEdge])
247
+ await nextTick()
248
+ emitElements()
249
+ }
250
+
251
+ const handleEdgeContextMenu = async (event: EdgeMouseEvent) => {
252
+ removeEdges([event.edge.id])
253
+ await nextTick()
254
+ emitElements()
255
+ }
256
+
257
+ const removeEdge = async (id: string) => {
258
+ removeEdges([id])
259
+ await nextTick()
260
+ emitElements()
257
261
  }
258
262
 
259
- const handleEdgeContextMenu = (event: EdgeMouseEvent) => {
260
- removeEdges(event.edge.id)
263
+ const emitElements = () => {
264
+ emit('update:modelValue', JSON.parse(JSON.stringify(vueFlowElements.value)))
261
265
  }
262
266
  </script>
263
267
 
@@ -5,132 +5,35 @@
5
5
  </template>
6
6
 
7
7
  <script setup lang="ts">
8
- import { type Node, Position } from '@vue-flow/core'
9
- import { type HTMLAttributes, computed } from 'vue'
8
+ import { type HTMLAttributes, computed, onMounted } from 'vue'
10
9
 
11
10
  import NodeEditor from './NodeEditor.vue'
12
- import type { EditorStates, FlowElement, FlowElements, Layout } from '../types'
11
+ import type { EditorStates, FlowElements, Layout } from '../types'
12
+ import { statesToFlowElements, flowElementsToStates } from '../utils/stateTransforms'
13
13
 
14
- const emit = defineEmits(['update:modelValue'])
15
14
  const states = defineModel<EditorStates>()
16
- const { layout, nodeContainerClass = '' } = defineProps<{
17
- layout: Layout
15
+ const layout = defineModel<Layout>('layout')
16
+ const { nodeContainerClass = '' } = defineProps<{
18
17
  nodeContainerClass?: HTMLAttributes['class']
19
18
  }>()
20
19
 
20
+ onMounted(() => {
21
+ if (layout.value === undefined) {
22
+ console.warn('[StateEditor] v-model:layout is not bound. Node position changes will not be persisted.')
23
+ }
24
+ })
25
+
21
26
  const elements = computed<FlowElements>({
22
27
  get: () => {
23
- const hasInputs = {}
24
- const stateElements: FlowElements = []
25
- const stateHash: Record<string, FlowElement> = {}
26
-
27
- if (!states.value) {
28
- return stateElements
29
- }
30
-
31
- let index = 0
32
- for (const [key, value] of Object.entries(states.value)) {
33
- const el: Node = {
34
- id: key,
35
- label: key,
36
- position: layout[key]?.position || { x: 200 * index, y: 100 },
37
- targetPosition: layout[key]?.targetPosition || Position.Left,
38
- sourcePosition: layout[key]?.sourcePosition || Position.Right,
39
- }
40
-
41
- if (value?.type === 'final') {
42
- el.type = 'output'
43
- el.class = 'default-output-node'
44
- }
45
-
46
- stateHash[key] = el
47
- }
48
-
49
- for (const [key, value] of Object.entries(states.value)) {
50
- if (value?.on) {
51
- for (const [edgeKey, edgeValue] of Object.entries(value.on)) {
52
- // NOTE: If the proxy array 'value.on' has more than one edge, 'edgeValue' will contain a proxy object
53
- // where 'target' can be accessed. Otherwise, 'edgeValue' will be available directly.
54
-
55
- // TODO: resolve type issues with xstate
56
- const target = edgeValue.target || edgeValue
57
- stateElements.push({
58
- id: `${key}-${target}`,
59
- source: key,
60
- target: target,
61
- label: edgeKey,
62
- animated: true,
63
- type: 'smoothstep', // Use smoothstep for better separation of bidirectional edges
64
- })
65
-
66
- hasInputs[target] = true
67
- }
68
- }
69
- index++
70
- }
71
-
72
- for (const [key, value] of Object.entries(stateHash)) {
73
- if (!hasInputs[key]) {
74
- value['type'] = 'input'
75
- value['class'] = 'default-input-node'
76
- }
77
- stateElements.push(value)
78
- }
79
-
80
- return stateElements
28
+ if (!states.value) return []
29
+ return statesToFlowElements(states.value, layout.value)
81
30
  },
82
-
83
31
  set: newValue => {
84
- // update modelValue when elements change
85
- onElementsChange(newValue)
86
-
87
- // TODO: emit('update:modelValue', modelValue)
32
+ const { states: nextStates, layout: nextLayout } = flowElementsToStates(newValue)
33
+ states.value = nextStates
34
+ if (layout.value !== undefined) {
35
+ layout.value = nextLayout
36
+ }
88
37
  },
89
38
  })
90
- const onElementsChange = (nextElements: FlowElements) => {
91
- const edges: Record<string, Record<string, any>> = {}
92
- const idToLabel: Record<string, string> = {}
93
- const nextStates: EditorStates = {}
94
-
95
- for (const el of nextElements) {
96
- const label = el.label as string
97
-
98
- if (el.type === 'input') {
99
- // it's an input node
100
- nextStates[label] = {
101
- on: {},
102
- }
103
- } else if (el.type === 'output') {
104
- // it's an output node
105
- nextStates[label] = {
106
- type: 'final',
107
- }
108
- } else if (el.source && el.target) {
109
- // it's an edge
110
- edges[el.source] = edges[el.source] || {}
111
- edges[el.source][label] = {
112
- target: el.target,
113
- }
114
- } else {
115
- // it's a state
116
- nextStates[label] = {
117
- on: {},
118
- }
119
- }
120
- idToLabel[el.id] = label
121
- }
122
-
123
- for (const [edgeKey, edgeValue] of Object.entries(edges)) {
124
- // add edges to states
125
- const label = idToLabel[edgeKey]
126
- for (const [key, value] of Object.entries(edgeValue)) {
127
- if (!nextStates[label]) {
128
- nextStates[label] = { on: {} }
129
- }
130
- nextStates[label].on[key] = value
131
- }
132
- }
133
-
134
- emit('update:modelValue', nextStates)
135
- }
136
39
  </script>
@@ -1,13 +1,11 @@
1
1
  import { type Elements, type Element, type XYPosition, Position } from '@vue-flow/core'
2
- import type { AnyStateNodeDefinition, AnyStateNodeConfig } from 'xstate'
2
+ import type { AnyStateNodeConfig } from 'xstate'
3
3
 
4
4
  /**
5
5
  * Editor states
6
6
  * @public
7
7
  */
8
- export type EditorStates = {
9
- [key: string]: AnyStateNodeDefinition | AnyStateNodeConfig['states']
10
- }
8
+ export type EditorStates = Record<string, AnyStateNodeConfig>
11
9
 
12
10
  /**
13
11
  * Flow elements
@@ -0,0 +1,117 @@
1
+ import { Position, type Node } from '@vue-flow/core'
2
+
3
+ import type { EditorStates, FlowElements, Layout } from '../types'
4
+
5
+ export function statesToFlowElements(states: EditorStates, layout?: Layout): FlowElements {
6
+ const hasInputs: Record<string, boolean> = {}
7
+ const stateElements: FlowElements = []
8
+ const stateHash: Record<string, Node> = {}
9
+
10
+ let index = 0
11
+ for (const [key, value] of Object.entries(states)) {
12
+ const el: Node = {
13
+ id: key,
14
+ label: key,
15
+ position: layout?.[key]?.position ?? { x: 200 * index, y: 100 },
16
+ targetPosition: layout?.[key]?.targetPosition ?? Position.Left,
17
+ sourcePosition: layout?.[key]?.sourcePosition ?? Position.Right,
18
+ }
19
+
20
+ if (value.type === 'final') {
21
+ el.type = 'output'
22
+ el.class = 'default-output-node'
23
+ }
24
+
25
+ stateHash[key] = el
26
+ index++
27
+ }
28
+
29
+ for (const [key, value] of Object.entries(states)) {
30
+ if (value.on) {
31
+ for (const [edgeKey, edgeValue] of Object.entries(value.on)) {
32
+ const target =
33
+ typeof edgeValue === 'string'
34
+ ? edgeValue
35
+ : edgeValue != null &&
36
+ typeof edgeValue === 'object' &&
37
+ 'target' in edgeValue &&
38
+ typeof edgeValue.target === 'string'
39
+ ? edgeValue.target
40
+ : ''
41
+ stateElements.push({
42
+ id: `${key}-${target}`,
43
+ source: key,
44
+ target,
45
+ label: edgeKey,
46
+ animated: true,
47
+ type: 'smoothstep',
48
+ interactionWidth: 40,
49
+ })
50
+ hasInputs[target] = true
51
+ }
52
+ }
53
+ }
54
+
55
+ for (const [key] of Object.entries(stateHash)) {
56
+ if (!hasInputs[key]) {
57
+ stateHash[key].type = 'input'
58
+ stateHash[key].class = 'default-input-node'
59
+ }
60
+ stateElements.push(stateHash[key])
61
+ }
62
+
63
+ return stateElements
64
+ }
65
+
66
+ export function flowElementsToStates(nextElements: FlowElements): { states: EditorStates; layout: Layout } {
67
+ const transitionsBySource: Record<string, Record<string, { target: string }>> = {}
68
+ const idToLabel: Record<string, string> = {}
69
+ const nodeTypeByLabel: Record<string, string | undefined> = {}
70
+ const nextLayout: Layout = {}
71
+
72
+ // First pass: build id→label map from nodes so edge targets resolve correctly
73
+ for (const el of nextElements) {
74
+ if (!('source' in el)) {
75
+ idToLabel[el.id] = typeof el.label === 'string' ? el.label : el.id
76
+ }
77
+ }
78
+
79
+ // Second pass: collect node types, layout positions, and edge transitions
80
+ for (const el of nextElements) {
81
+ if ('source' in el && 'target' in el) {
82
+ const edgeLabel = typeof el.label === 'string' ? el.label : el.id
83
+ const sourceLabel = idToLabel[el.source] || el.source
84
+ const targetLabel = idToLabel[el.target] || el.target
85
+ transitionsBySource[sourceLabel] = transitionsBySource[sourceLabel] ?? {}
86
+ transitionsBySource[sourceLabel][edgeLabel] = { target: targetLabel }
87
+ } else {
88
+ const nodeLabel = typeof el.label === 'string' ? el.label : el.id
89
+ nodeTypeByLabel[nodeLabel] = el.type
90
+ if (el.position) {
91
+ nextLayout[nodeLabel] = {
92
+ position: el.position,
93
+ ...(el.targetPosition !== undefined && { targetPosition: el.targetPosition }),
94
+ ...(el.sourcePosition !== undefined && { sourcePosition: el.sourcePosition }),
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ // Build states from node types and collected transitions
101
+ const nextStates: EditorStates = {}
102
+ for (const [label, nodeType] of Object.entries(nodeTypeByLabel)) {
103
+ if (nodeType === 'output') {
104
+ nextStates[label] = { type: 'final' }
105
+ } else {
106
+ nextStates[label] = { on: transitionsBySource[label] ?? {} }
107
+ }
108
+ }
109
+ // Capture any transition sources that had no corresponding node entry
110
+ for (const [sourceLabel, on] of Object.entries(transitionsBySource)) {
111
+ if (!nextStates[sourceLabel]) {
112
+ nextStates[sourceLabel] = { on }
113
+ }
114
+ }
115
+
116
+ return { states: nextStates, layout: nextLayout }
117
+ }