@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.
- package/dist/node-editor.d.ts +1 -4
- package/dist/node-editor.js +2545 -2537
- package/dist/node-editor.js.map +1 -1
- package/dist/src/types/index.d.ts +2 -4
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/src/utils/stateTransforms.d.ts +7 -0
- package/dist/src/utils/stateTransforms.d.ts.map +1 -0
- package/dist/src/utils/stateTransforms.js +104 -0
- package/package.json +7 -2
- package/src/components/EditableEdge.vue +5 -5
- package/src/components/EditableNode.vue +1 -1
- package/src/components/NodeEditor.vue +45 -41
- package/src/components/StateEditor.vue +18 -115
- package/src/types/index.ts +2 -4
- package/src/utils/stateTransforms.ts +117 -0
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { type Elements, type Element, type XYPosition, Position } from '@vue-flow/core';
|
|
2
|
-
import type {
|
|
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,
|
|
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.
|
|
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']>('')
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
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,
|
|
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.
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
260
|
-
|
|
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
|
|
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,
|
|
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
|
|
17
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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>
|
package/src/types/index.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { type Elements, type Element, type XYPosition, Position } from '@vue-flow/core'
|
|
2
|
-
import type {
|
|
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
|
+
}
|