@stonecrop/node-editor 0.13.12 → 0.14.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.
@@ -1,104 +1,148 @@
1
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,
2
+ export function statesToFlowElements(workflow, layout) {
3
+ const { states = [], actions = {} } = workflow;
4
+ const edges = [];
5
+ const nodes = [];
6
+ for (const [actionKey, actionDef] of Object.entries(actions)) {
7
+ // Stateless commands (print/email) have no graph presence; anything graph-owned needs states.
8
+ if (actionDef.stateless || !actionDef.allowedStates?.length)
9
+ continue;
10
+ // A self-transition (mutate-in-place `save`) renders as a self-loop on each allowed state:
11
+ // `source === target`, tagged `selfloop` so NodeEditor routes it to the arc renderer.
12
+ if (actionDef.selfTransition) {
13
+ for (const source of actionDef.allowedStates) {
14
+ edges.push({
15
+ id: `${actionKey}-${source}`,
16
+ source,
17
+ target: source,
18
+ label: actionDef.label ?? actionKey,
19
+ data: { actionKey },
38
20
  animated: true,
39
- type: 'smoothstep',
21
+ type: 'selfloop',
40
22
  interactionWidth: 40,
41
23
  });
42
- hasInputs[target] = true;
43
24
  }
25
+ continue;
44
26
  }
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';
27
+ // A cross-state transition needs a target; a malformed action with neither is skipped.
28
+ if (!actionDef.nextState)
29
+ continue;
30
+ for (const source of actionDef.allowedStates) {
31
+ edges.push({
32
+ id: `${actionKey}-${source}`,
33
+ source,
34
+ target: actionDef.nextState,
35
+ // The edge paints the human display label; its identity (the action key) rides in
36
+ // `data.actionKey`, so relabeling the edge renames the action without re-keying it.
37
+ label: actionDef.label ?? actionKey,
38
+ data: { actionKey },
39
+ animated: true,
40
+ type: 'smoothstep',
41
+ interactionWidth: 40,
42
+ });
50
43
  }
51
- stateElements.push(stateHash[key]);
52
44
  }
53
- return stateElements;
45
+ for (let index = 0; index < states.length; index++) {
46
+ const state = states[index];
47
+ const node = {
48
+ id: state,
49
+ label: state,
50
+ position: layout?.[state]?.position ?? { x: 200 * index, y: 100 },
51
+ targetPosition: layout?.[state]?.targetPosition ?? Position.Left,
52
+ sourcePosition: layout?.[state]?.sourcePosition ?? Position.Right,
53
+ };
54
+ // Every state renders identically — both handles, no start-state styling — so any transition
55
+ // (including a returning `reject`/`reopen` into the initial state) is authorable and a
56
+ // self-loop can anchor both ends. Marking an entry state is deferred (YAGNI) until needed.
57
+ nodes.push(node);
58
+ }
59
+ return [...edges, ...nodes];
54
60
  }
55
- export function flowElementsToStates(nextElements) {
56
- const transitionsBySource = {};
61
+ export function flowElementsToStates(nextElements, existingWorkflow) {
57
62
  const idToLabel = {};
58
- const nodeTypeByLabel = {};
59
63
  const nextLayout = {};
60
- // First pass: build id→label map from nodes so edge targets resolve correctly
64
+ const stateNames = [];
61
65
  for (const el of nextElements) {
62
- if (!('source' in el)) {
63
- idToLabel[el.id] = typeof el.label === 'string' ? el.label : el.id;
66
+ if ('source' in el)
67
+ continue;
68
+ const label = typeof el.label === 'string' ? el.label : el.id;
69
+ idToLabel[el.id] = label;
70
+ stateNames.push(label);
71
+ if (el.position) {
72
+ nextLayout[label] = {
73
+ position: el.position,
74
+ ...(el.targetPosition !== undefined && { targetPosition: el.targetPosition }),
75
+ ...(el.sourcePosition !== undefined && { sourcePosition: el.sourcePosition }),
76
+ };
64
77
  }
65
78
  }
66
- // Second pass: collect node types, layout positions, and edge transitions
79
+ // Group directed edges by their stable action key. A round-tripped edge carries the key in
80
+ // `data.actionKey`; a freshly-drawn edge (no data yet) falls back to its label as the key — the
81
+ // one moment label and key coincide, when the action is first born. The edge's label is captured
82
+ // separately as the display name, so a later relabel renames the action without re-keying it.
83
+ const transitionGroups = {};
67
84
  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 };
85
+ if (!('source' in el))
86
+ continue;
87
+ const edgeLabel = typeof el.label === 'string' ? el.label : el.id;
88
+ const actionKey = el.data?.actionKey ?? edgeLabel;
89
+ const sourceLabel = idToLabel[el.source] || el.source;
90
+ const targetLabel = idToLabel[el.target] || el.target;
91
+ // Classify by topology, not by edge `type`: a self-loop (source === target) round-trips to a
92
+ // self-transition regardless of how it was authored (drawn node→itself, or re-rendered from a
93
+ // `selfTransition` action). A group's kind is set by its first edge; a multi-state self-loop
94
+ // (e.g. `save` on Draft AND Pending) accumulates each source into allowedStates.
95
+ const isSelf = el.source === el.target;
96
+ if (!transitionGroups[actionKey]) {
97
+ transitionGroups[actionKey] = {
98
+ allowedStates: [sourceLabel],
99
+ label: edgeLabel,
100
+ selfLoop: isSelf,
101
+ ...(isSelf ? {} : { nextState: targetLabel }),
102
+ };
74
103
  }
75
104
  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
- }
105
+ transitionGroups[actionKey].allowedStates.push(sourceLabel);
85
106
  }
86
107
  }
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
- }
108
+ const nextActions = {};
109
+ // Transitions (and self-transitions) derived from graph edges
110
+ for (const [actionKey, group] of Object.entries(transitionGroups)) {
111
+ const existing = existingWorkflow?.actions?.[actionKey];
112
+ nextActions[actionKey] = {
113
+ // Spread existing first so every field the graph does NOT own — clientHandler,
114
+ // requiredFields and any field added to ActionDefinition later — survives the
115
+ // round-trip. The graph owns topology (allowedStates/nextState/selfTransition) and the
116
+ // display label. (Enumerating named fields here previously dropped clientHandler.)
117
+ ...existing,
118
+ // The edge's label is the display name (decoupled from the key). Fall back to the
119
+ // existing label, then the key, if an edge ever arrives label-less.
120
+ label: group.label || existing?.label || actionKey,
121
+ // The graph owns topology only. A self-transition (self-loop) stays in place: mark
122
+ // `selfTransition`, carry NO `nextState`. A cross-state transition carries `nextState`
123
+ // and clears any stale self flag (a self-loop redrawn as a normal edge). Explicit
124
+ // `undefined` on the unused field is dropped by JSON.stringify, so no format churn.
125
+ allowedStates: group.allowedStates,
126
+ nextState: group.selfLoop ? undefined : group.nextState,
127
+ selfTransition: group.selfLoop ? true : undefined,
128
+ };
96
129
  }
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 };
130
+ // Pass through Verbs (stateless: true) and global Workflow actions (no allowedStates) verbatim
131
+ for (const [actionKey, actionDef] of Object.entries(existingWorkflow?.actions ?? {})) {
132
+ if (actionKey in transitionGroups)
133
+ continue;
134
+ if (actionDef.stateless || !actionDef.allowedStates?.length) {
135
+ nextActions[actionKey] = actionDef;
101
136
  }
137
+ // Workflow action with allowedStates not in graph = user deleted its edges → remove
102
138
  }
103
- return { states: nextStates, layout: nextLayout };
139
+ return {
140
+ // Spread existingWorkflow first so every top-level key the graph does NOT own — the
141
+ // sibling `triggers` map (field-validation triggers), and any WorkflowMeta key added
142
+ // later — survives the round-trip. The graph owns topology only: states + actions,
143
+ // overridden below. (Same principle the per-action spread applies above; enumerating
144
+ // only states+actions here previously dropped `triggers` on every graph edit.)
145
+ workflow: { ...existingWorkflow, states: stateNames, actions: nextActions },
146
+ layout: nextLayout,
147
+ };
104
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonecrop/node-editor",
3
- "version": "0.13.12",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "author": {
@@ -32,8 +32,9 @@
32
32
  "**/*.css"
33
33
  ],
34
34
  "dependencies": {
35
+ "@dagrejs/dagre": "^3.0.0",
35
36
  "@vue-flow/core": "^1.48.1",
36
- "xstate": "^5.25.0"
37
+ "@stonecrop/schema": "0.14.0"
37
38
  },
38
39
  "peerDependencies": {
39
40
  "vue": "^3.5.33",
@@ -51,6 +52,7 @@
51
52
  "vitest": "^4.1.5",
52
53
  "vue": "^3.5.33",
53
54
  "vue-router": "^5.0.6",
55
+ "vue-tsc": "^3.3.6",
54
56
  "stonecrop-rig": "0.7.0"
55
57
  },
56
58
  "description": "Node editor UI for Stonecrop",
@@ -69,6 +71,7 @@
69
71
  "lint:fix": "oxlint --fix",
70
72
  "preview": "vite preview",
71
73
  "test": "vitest run --coverage.enabled false",
74
+ "test:types": "vue-tsc --noEmit",
72
75
  "test:watch": "vitest watch",
73
76
  "test:coverage": "vitest run --coverage"
74
77
  }
@@ -50,7 +50,7 @@ const showLabelInput = async () => {
50
50
  newLabel.value = props.label
51
51
  showInput.value = true
52
52
  await nextTick()
53
- inputRef.value.focus()
53
+ inputRef.value?.focus()
54
54
  }
55
55
 
56
56
  const submitNewLabel = () => {
@@ -16,6 +16,9 @@
16
16
  <div>
17
17
  <button class="button-default" @click="fitView">Center</button>
18
18
  </div>
19
+ <div>
20
+ <button class="button-default" @click="autoArrange">Auto-arrange</button>
21
+ </div>
19
22
  <div v-if="activeElementIndex > -1">
20
23
  <button class="button-default" @click="shiftInput">Shift Input Position</button>
21
24
  </div>
@@ -32,6 +35,7 @@
32
35
  :prevent-scrolling="true"
33
36
  :zoom-on-scroll="false"
34
37
  :fit-view-on-init="true"
38
+ :pan-activation-key-code="null"
35
39
  @connect="handleConnect"
36
40
  @pane-ready="setInstance"
37
41
  @node-click="handleNodeClick"
@@ -45,6 +49,9 @@
45
49
  <template #edge-editable="props">
46
50
  <EditableEdge v-bind="props" @change="labelChanged($event, props.id)" @remove="removeEdge(props.id)" />
47
51
  </template>
52
+ <template #edge-selfloop="props">
53
+ <SelfLoopEdge v-bind="props" @change="labelChanged($event, props.id)" @remove="removeEdge(props.id)" />
54
+ </template>
48
55
  </VueFlow>
49
56
  </div>
50
57
  </template>
@@ -65,6 +72,8 @@ import { type HTMLAttributes, ref, computed, nextTick, onBeforeUnmount, onMounte
65
72
 
66
73
  import EditableEdge from './EditableEdge.vue'
67
74
  import EditableNode from './EditableNode.vue'
75
+ import SelfLoopEdge from './SelfLoopEdge.vue'
76
+ import { autoLayout } from '../utils/autoLayout'
68
77
  import type { FlowElements } from '../types'
69
78
 
70
79
  const { modelValue, nodeContainerClass = '' } = defineProps<{
@@ -86,9 +95,11 @@ const elements = computed({
86
95
  get: () => {
87
96
  const items = modelValue
88
97
 
89
- // Add data to each element
98
+ // Add rendering flags without clobbering caller-provided data. Edges carry `data.actionKey`
99
+ // (the stable action identity from stateTransforms); wiping data here would drop it before it
100
+ // round-trips back through emitElements, silently re-keying actions on every graph edit.
90
101
  for (const element of items) {
91
- element.data = {}
102
+ element.data = { ...element.data }
92
103
  if (element.type === 'input') {
93
104
  element.data.hasInput = false
94
105
  element.data.hasOutput = true
@@ -100,7 +111,10 @@ const elements = computed({
100
111
  element.data.hasOutput = true
101
112
  }
102
113
  element.class = 'vue-flow__node-default'
103
- element.type = 'editable'
114
+ // A self-loop edge (source === target) routes to the SelfLoopEdge arc renderer; everything
115
+ // else (nodes and cross-state edges) uses the default editable slot. getBezierPath draws a
116
+ // useless near-straight segment for a self-loop, so it must not fall through to 'editable'.
117
+ element.type = 'source' in element && element.source === element.target ? 'selfloop' : 'editable'
104
118
  }
105
119
 
106
120
  return items
@@ -173,6 +187,35 @@ const fitView = async () => {
173
187
  await vueFlowInstance.value?.fitView()
174
188
  }
175
189
 
190
+ const autoArrange = async () => {
191
+ // Read measured node sizes from the VueFlow store so the layout fits actual box widths (state
192
+ // labels differ in length); autoLayout falls back to default dimensions for any unmeasured node.
193
+ const store = vueFlowInstance.value
194
+ const dimensions: Record<string, { width: number; height: number }> = {}
195
+ for (const el of vueFlowElements.value) {
196
+ if ('source' in el) continue
197
+ const dims = store?.findNode?.(el.id)?.dimensions
198
+ if (dims?.width && dims?.height) dimensions[el.id] = { width: dims.width, height: dims.height }
199
+ }
200
+
201
+ const laid = autoLayout(vueFlowElements.value, { dimensions })
202
+ const positionById = new Map<string, { x: number; y: number }>()
203
+ for (const el of laid) {
204
+ if ('source' in el) continue
205
+ positionById.set(el.id, el.position)
206
+ }
207
+ // Apply positions onto the v-model elements — VueFlow reacts to these, same as shiftInput/Output.
208
+ for (const el of vueFlowElements.value) {
209
+ if ('source' in el) continue
210
+ const position = positionById.get(el.id)
211
+ if (position) el.position = position
212
+ }
213
+
214
+ emitElements()
215
+ await nextTick()
216
+ await fitView()
217
+ }
218
+
176
219
  const addNode = () => {
177
220
  let makeEdge = false
178
221
  let newNodePosition = { x: Math.random() * 200, y: Math.random() * 200 }
@@ -234,11 +277,13 @@ const handleEdgeClick = ({ edge }: EdgeMouseEvent) => {
234
277
 
235
278
  const handleConnect = async (event: Connection) => {
236
279
  const id = vueFlowElements.value.length
280
+ // A node connected to itself is a self-transition (mutate-in-place) — render it as a loop.
281
+ const isSelfLoop = event.source === event.target
237
282
  const newEdge = {
238
283
  id: `edge-${id}`,
239
284
  source: event.source,
240
285
  target: event.target,
241
- type: 'editable',
286
+ type: isSelfLoop ? 'selfloop' : 'editable',
242
287
  label: `New Edge`,
243
288
  interactionWidth: 400,
244
289
  animated: true,
@@ -283,17 +328,23 @@ const emitElements = () => {
283
328
  }
284
329
 
285
330
  .chart-controls {
286
- padding-left: 20px;
287
- padding-right: 20px;
288
- height: 1.8em;
289
- border-bottom: 1px solid #ccc;
331
+ position: absolute;
332
+ bottom: 0.5rem;
333
+ left: 0.5rem;
334
+ z-index: 10;
290
335
  display: flex;
291
- flex-direction: row;
292
- justify-content: space-between;
336
+ flex-direction: column;
337
+ align-items: flex-start;
338
+ gap: 0.35rem;
339
+ padding: 0.4rem 0.5rem;
340
+ background: rgba(255, 255, 255, 0.92);
341
+ border: 1px solid #ccc;
342
+ border-radius: 4px;
343
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
293
344
  }
294
345
 
295
346
  .chart-controls div {
296
- margin-bottom: 5px;
347
+ margin-bottom: 0;
297
348
  }
298
349
 
299
350
  .defaultContainerClass {
@@ -0,0 +1,88 @@
1
+ <template>
2
+ <!-- transparent ghost path gives a ~20px click zone around the 2px visible stroke -->
3
+ <path :d="path.d" fill="none" stroke="transparent" stroke-width="20" class="vue-flow__edge-interaction" />
4
+ <path :id="id" :style="style" class="vue-flow__edge-path" :d="path.d" :marker-end="markerEnd" />
5
+
6
+ <EdgeLabelRenderer>
7
+ <div
8
+ :style="{
9
+ pointerEvents: 'all',
10
+ position: 'absolute',
11
+ transform: `translate(-50%, -50%) translate(${path.labelX}px,${path.labelY}px)`,
12
+ }"
13
+ class="nodrag nopan editable-edge-label"
14
+ @click="labelOnClick()"
15
+ @contextmenu.prevent="$emit('remove', id)">
16
+ <div class="vue-flow__edge-label">{{ label }}</div>
17
+ <div v-if="showInput" class="label-input-wrapper">
18
+ <input
19
+ ref="labelInput"
20
+ v-model="newLabel"
21
+ class="label-input"
22
+ @blur="showInput = false"
23
+ @keypress.enter="submitNewLabel" />
24
+ </div>
25
+ </div>
26
+ </EdgeLabelRenderer>
27
+ </template>
28
+
29
+ <script setup lang="ts">
30
+ import { type EdgeProps, EdgeLabelRenderer } from '@vue-flow/core'
31
+ import { computed, ref, nextTick, useTemplateRef } from 'vue'
32
+
33
+ const props = defineProps<EdgeProps>()
34
+ const emit = defineEmits(['change', 'remove'])
35
+
36
+ // A self-loop connects a node's source handle to its own target handle. getBezierPath draws a nearly
37
+ // straight segment across the node for that (the two handles are on the same box), so it's useless
38
+ // here — we hand-author an arc that leaves the source handle, loops up and over the node top, and
39
+ // returns to the target handle. LOOP_HEIGHT is how far above the handles the arc bulges.
40
+ const LOOP_HEIGHT = 90
41
+ const LOOP_SPREAD = 45
42
+
43
+ const path = computed(() => {
44
+ const { sourceX, sourceY, targetX, targetY } = props
45
+ const d = `M ${sourceX},${sourceY} C ${sourceX + LOOP_SPREAD},${sourceY - LOOP_HEIGHT} ${
46
+ targetX - LOOP_SPREAD
47
+ },${targetY - LOOP_HEIGHT} ${targetX},${targetY}`
48
+ // Place the label near the arc's apex (a cubic bezier peaks around 0.75 of the control offset).
49
+ const labelX = (sourceX + targetX) / 2
50
+ const labelY = Math.min(sourceY, targetY) - LOOP_HEIGHT * 0.72
51
+ return { d, labelX, labelY }
52
+ })
53
+
54
+ const inputRef = useTemplateRef<HTMLInputElement>('labelInput')
55
+ const newLabel = ref<EdgeProps['label']>('')
56
+ const showInput = ref(false)
57
+ let lastClick = 0
58
+
59
+ const labelOnClick = async () => {
60
+ let now = Date.now()
61
+ if (now - lastClick < 500 && !showInput.value) {
62
+ await showLabelInput()
63
+ }
64
+ lastClick = now
65
+ }
66
+
67
+ const showLabelInput = async () => {
68
+ newLabel.value = props.label
69
+ showInput.value = true
70
+ await nextTick()
71
+ inputRef.value?.focus()
72
+ }
73
+
74
+ const submitNewLabel = () => {
75
+ showInput.value = false
76
+ emit('change', newLabel.value)
77
+ }
78
+ </script>
79
+
80
+ <script lang="ts">
81
+ export default {
82
+ inheritAttrs: false,
83
+ }
84
+ </script>
85
+
86
+ <style>
87
+ /* Label + input styles are shared with EditableEdge (defined there, global scope). */
88
+ </style>
@@ -6,12 +6,14 @@
6
6
 
7
7
  <script setup lang="ts">
8
8
  import { type HTMLAttributes, computed, onMounted } from 'vue'
9
+ import type { WorkflowMeta } from '@stonecrop/schema'
9
10
 
10
11
  import NodeEditor from './NodeEditor.vue'
11
- import type { EditorStates, FlowElements, Layout } from '../types'
12
+ import type { FlowElements, Layout } from '../types'
13
+ import { autoLayout } from '../utils/autoLayout'
12
14
  import { statesToFlowElements, flowElementsToStates } from '../utils/stateTransforms'
13
15
 
14
- const states = defineModel<EditorStates>()
16
+ const workflow = defineModel<WorkflowMeta>()
15
17
  const layout = defineModel<Layout>('layout')
16
18
  const { nodeContainerClass = '' } = defineProps<{
17
19
  nodeContainerClass?: HTMLAttributes['class']
@@ -25,12 +27,17 @@ onMounted(() => {
25
27
 
26
28
  const elements = computed<FlowElements>({
27
29
  get: () => {
28
- if (!states.value) return []
29
- return statesToFlowElements(states.value, layout.value)
30
+ if (!workflow.value) return []
31
+ const els = statesToFlowElements(workflow.value, layout.value)
32
+ // Seed an un-arranged workflow with an auto-computed dagre layout instead of the naive row.
33
+ // Ephemeral: these positions are persisted only once the author drags a node (which routes
34
+ // through flowElementsToStates → layout). A workflow with any saved layout keeps it as-is.
35
+ const noSavedLayout = !layout.value || Object.keys(layout.value).length === 0
36
+ return noSavedLayout ? autoLayout(els) : els
30
37
  },
31
38
  set: newValue => {
32
- const { states: nextStates, layout: nextLayout } = flowElementsToStates(newValue)
33
- states.value = nextStates
39
+ const { workflow: nextWorkflow, layout: nextLayout } = flowElementsToStates(newValue, workflow.value)
40
+ workflow.value = nextWorkflow
34
41
  if (layout.value !== undefined) {
35
42
  layout.value = nextLayout
36
43
  }
@@ -1,11 +1,4 @@
1
1
  import { type Elements, type Element, type XYPosition, Position } from '@vue-flow/core'
2
- import type { AnyStateNodeConfig } from 'xstate'
3
-
4
- /**
5
- * Editor states
6
- * @public
7
- */
8
- export type EditorStates = Record<string, AnyStateNodeConfig>
9
2
 
10
3
  /**
11
4
  * Flow elements
@@ -13,7 +6,9 @@ export type EditorStates = Record<string, AnyStateNodeConfig>
13
6
  */
14
7
  export type FlowElements = Elements<
15
8
  { hasInput?: boolean; hasOutput?: boolean },
16
- { hasInput?: boolean; hasOutput?: boolean }
9
+ // `actionKey` is the stable action identity carried on an edge, decoupled from its visible
10
+ // `label` (the display name). It lets an edge be relabeled without re-keying the action.
11
+ { hasInput?: boolean; hasOutput?: boolean; actionKey?: string }
17
12
  >
18
13
 
19
14
  /**
@@ -22,7 +17,7 @@ export type FlowElements = Elements<
22
17
  */
23
18
  export type FlowElement = Element<
24
19
  { hasInput?: boolean; hasOutput?: boolean },
25
- { hasInput?: boolean; hasOutput?: boolean }
20
+ { hasInput?: boolean; hasOutput?: boolean; actionKey?: string }
26
21
  >
27
22
 
28
23
  /**
@@ -0,0 +1,67 @@
1
+ import { Graph, layout } from '@dagrejs/dagre'
2
+
3
+ import type { FlowElements } from '../types'
4
+
5
+ /**
6
+ * Options for {@link autoLayout}.
7
+ * @public
8
+ */
9
+ export interface AutoLayoutOptions {
10
+ /** Layout direction. Defaults to `'LR'` to match the editor's Left/Right handle defaults. */
11
+ rankdir?: 'LR' | 'RL' | 'TB' | 'BT'
12
+ /** Fallback node width when no measured size is supplied (the seed path has no DOM to measure). */
13
+ nodeWidth?: number
14
+ /** Fallback node height when no measured size is supplied. */
15
+ nodeHeight?: number
16
+ /** Measured per-node sizes (node id → box) — supplied by the on-demand "Auto-arrange" button. */
17
+ dimensions?: Record<string, { width: number; height: number }>
18
+ }
19
+
20
+ /**
21
+ * Compute node positions for a workflow graph with dagre's layered (Sugiyama) algorithm.
22
+ *
23
+ * Pure and synchronous: takes VueFlow elements (nodes + edges) and returns a new elements array with
24
+ * each node's `position` replaced by a laid-out coordinate; edges and every other node field are
25
+ * passed through untouched. Used two ways — as the seed for an un-arranged workflow (fixed default
26
+ * dimensions, no DOM) and behind the toolbar button (measured `dimensions`).
27
+ *
28
+ * Self-loop edges (`source === target`) are skipped: dagre doesn't rank them, and they're reserved
29
+ * for the separate mutate-in-place self-transition feature.
30
+ * @public
31
+ */
32
+ export function autoLayout(elements: FlowElements, options: AutoLayoutOptions = {}): FlowElements {
33
+ const { rankdir = 'LR', nodeWidth = 160, nodeHeight = 40, dimensions } = options
34
+ const sizeOf = (id: string) => dimensions?.[id] ?? { width: nodeWidth, height: nodeHeight }
35
+
36
+ // Nodes are the elements without a `source` (the same discriminator flowElementsToStates uses).
37
+ const nodeIds: string[] = []
38
+ for (const el of elements) {
39
+ if (!('source' in el)) nodeIds.push(el.id)
40
+ }
41
+ if (nodeIds.length === 0) return [...elements]
42
+
43
+ const graph = new Graph()
44
+ graph.setGraph({ rankdir, nodesep: 40, ranksep: 80, edgesep: 20 })
45
+ graph.setDefaultEdgeLabel(() => ({}))
46
+
47
+ for (const id of nodeIds) {
48
+ const { width, height } = sizeOf(id)
49
+ graph.setNode(id, { width, height })
50
+ }
51
+ for (const el of elements) {
52
+ if (!('source' in el)) continue
53
+ if (el.source === el.target) continue
54
+ graph.setEdge(el.source, el.target)
55
+ }
56
+
57
+ layout(graph)
58
+
59
+ // dagre reports each node by its CENTER; VueFlow positions nodes by their TOP-LEFT corner.
60
+ return elements.map(el => {
61
+ if ('source' in el) return el
62
+ const center = graph.node(el.id)
63
+ if (!center) return el
64
+ const { width, height } = sizeOf(el.id)
65
+ return { ...el, position: { x: center.x - width / 2, y: center.y - height / 2 } }
66
+ })
67
+ }