@stonecrop/node-editor 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index.css +430 -0
- package/dist/node-editor.js +821 -644
- package/dist/node-editor.js.map +1 -1
- package/dist/src/components/EditableEdge.vue.d.ts +11 -0
- package/dist/src/components/EditableEdge.vue.d.ts.map +1 -0
- package/dist/src/components/EditableNode.vue.d.ts +10 -0
- package/dist/src/components/EditableNode.vue.d.ts.map +1 -0
- package/dist/src/components/NodeEditor.vue.d.ts +14 -0
- package/dist/src/components/NodeEditor.vue.d.ts.map +1 -0
- package/dist/src/components/SelfLoopEdge.vue.d.ts +11 -0
- package/dist/src/components/SelfLoopEdge.vue.d.ts.map +1 -0
- package/dist/src/components/StateEditor.vue.d.ts +69 -0
- package/dist/src/components/StateEditor.vue.d.ts.map +1 -0
- package/dist/{tsdoc-metadata.json → src/tsdoc-metadata.json} +1 -1
- package/package.json +35 -28
- package/dist/node-editor.css +0 -1
- package/dist/node-editor.d.ts +0 -60
- package/dist/node_editor.tsbuildinfo +0 -1
- package/dist/src/index.js +0 -16
- package/dist/src/types/index.js +0 -0
- package/dist/src/utils/autoLayout.js +0 -50
- package/dist/src/utils/stateTransforms.js +0 -148
- package/src/components/EditableEdge.vue +0 -92
- package/src/components/EditableNode.vue +0 -65
- package/src/components/NodeEditor.vue +0 -397
- package/src/components/SelfLoopEdge.vue +0 -88
- package/src/components/StateEditor.vue +0 -46
- package/src/index.ts +0 -22
- package/src/shims-vue.d.ts +0 -5
- package/src/types/index.ts +0 -33
- package/src/utils/autoLayout.ts +0 -67
- package/src/utils/stateTransforms.ts +0 -164
|
@@ -1,397 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<div
|
|
3
|
-
class="node-editor-wrapper"
|
|
4
|
-
:class="nodeContainerClass"
|
|
5
|
-
@contextmenu.prevent
|
|
6
|
-
@mouseover="hover = true"
|
|
7
|
-
@mouseleave="hover = false">
|
|
8
|
-
<div class="chart-controls">
|
|
9
|
-
<div class="chart-controls-left">
|
|
10
|
-
<div><b>Selected Node:</b> {{ activeElementKey ? activeElementKey : 'none' }}</div>
|
|
11
|
-
</div>
|
|
12
|
-
<div class="chart-controls-right">
|
|
13
|
-
<div>
|
|
14
|
-
<button class="button-default" @click="addNode">Add Node</button>
|
|
15
|
-
</div>
|
|
16
|
-
<div>
|
|
17
|
-
<button class="button-default" @click="fitView">Center</button>
|
|
18
|
-
</div>
|
|
19
|
-
<div>
|
|
20
|
-
<button class="button-default" @click="autoArrange">Auto-arrange</button>
|
|
21
|
-
</div>
|
|
22
|
-
<div v-if="activeElementIndex > -1">
|
|
23
|
-
<button class="button-default" @click="shiftInput">Shift Input Position</button>
|
|
24
|
-
</div>
|
|
25
|
-
<div v-if="activeElementIndex > -1">
|
|
26
|
-
<button class="button-default" @click="shiftOutput">Shift Output Position</button>
|
|
27
|
-
</div>
|
|
28
|
-
</div>
|
|
29
|
-
</div>
|
|
30
|
-
|
|
31
|
-
<VueFlow
|
|
32
|
-
v-if="vueFlowElements && vueFlowElements.length"
|
|
33
|
-
v-model="vueFlowElements"
|
|
34
|
-
class="nowheel"
|
|
35
|
-
:prevent-scrolling="true"
|
|
36
|
-
:zoom-on-scroll="false"
|
|
37
|
-
:fit-view-on-init="true"
|
|
38
|
-
:pan-activation-key-code="null"
|
|
39
|
-
@connect="handleConnect"
|
|
40
|
-
@pane-ready="setInstance"
|
|
41
|
-
@node-click="handleNodeClick"
|
|
42
|
-
@edge-click="handleEdgeClick"
|
|
43
|
-
@edge-context-menu="handleEdgeContextMenu"
|
|
44
|
-
@node-drag-stop="emitElements"
|
|
45
|
-
@wheel.prevent="onWheel">
|
|
46
|
-
<template #node-editable="props">
|
|
47
|
-
<EditableNode v-bind="props" @change="labelChanged($event, props.id)" />
|
|
48
|
-
</template>
|
|
49
|
-
<template #edge-editable="props">
|
|
50
|
-
<EditableEdge v-bind="props" @change="labelChanged($event, props.id)" @remove="removeEdge(props.id)" />
|
|
51
|
-
</template>
|
|
52
|
-
<template #edge-selfloop="props">
|
|
53
|
-
<SelfLoopEdge v-bind="props" @change="labelChanged($event, props.id)" @remove="removeEdge(props.id)" />
|
|
54
|
-
</template>
|
|
55
|
-
</VueFlow>
|
|
56
|
-
</div>
|
|
57
|
-
</template>
|
|
58
|
-
|
|
59
|
-
<script setup lang="ts">
|
|
60
|
-
import {
|
|
61
|
-
type VueFlowStore,
|
|
62
|
-
Position,
|
|
63
|
-
VueFlow,
|
|
64
|
-
useVueFlow,
|
|
65
|
-
Connection,
|
|
66
|
-
Node,
|
|
67
|
-
NodeMouseEvent,
|
|
68
|
-
EdgeMouseEvent,
|
|
69
|
-
DefaultEdge,
|
|
70
|
-
} from '@vue-flow/core'
|
|
71
|
-
import { type HTMLAttributes, ref, computed, nextTick, onBeforeUnmount, onMounted } from 'vue'
|
|
72
|
-
|
|
73
|
-
import EditableEdge from './EditableEdge.vue'
|
|
74
|
-
import EditableNode from './EditableNode.vue'
|
|
75
|
-
import SelfLoopEdge from './SelfLoopEdge.vue'
|
|
76
|
-
import { autoLayout } from '../utils/autoLayout'
|
|
77
|
-
import type { FlowElements } from '../types'
|
|
78
|
-
|
|
79
|
-
const { modelValue, nodeContainerClass = '' } = defineProps<{
|
|
80
|
-
modelValue: FlowElements
|
|
81
|
-
nodeContainerClass?: HTMLAttributes['class']
|
|
82
|
-
}>()
|
|
83
|
-
const emit = defineEmits(['update:modelValue'])
|
|
84
|
-
|
|
85
|
-
const hover = ref(false)
|
|
86
|
-
const vueFlowElements = ref<FlowElements>([])
|
|
87
|
-
const vueFlowInstance = ref<VueFlowStore>()
|
|
88
|
-
|
|
89
|
-
const activeElementKey = ref('')
|
|
90
|
-
const activeElementIndex = computed(() =>
|
|
91
|
-
vueFlowElements.value.findIndex(element => element.id === activeElementKey.value)
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
const elements = computed({
|
|
95
|
-
get: () => {
|
|
96
|
-
const items = modelValue
|
|
97
|
-
|
|
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.
|
|
101
|
-
for (const element of items) {
|
|
102
|
-
element.data = { ...element.data }
|
|
103
|
-
if (element.type === 'input') {
|
|
104
|
-
element.data.hasInput = false
|
|
105
|
-
element.data.hasOutput = true
|
|
106
|
-
} else if (element.type === 'output') {
|
|
107
|
-
element.data.hasInput = true
|
|
108
|
-
element.data.hasOutput = false
|
|
109
|
-
} else {
|
|
110
|
-
element.data.hasInput = true
|
|
111
|
-
element.data.hasOutput = true
|
|
112
|
-
}
|
|
113
|
-
element.class = 'vue-flow__node-default'
|
|
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'
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return items
|
|
121
|
-
},
|
|
122
|
-
set: newValue => {
|
|
123
|
-
emit('update:modelValue', JSON.parse(JSON.stringify(newValue)))
|
|
124
|
-
},
|
|
125
|
-
})
|
|
126
|
-
const { addEdges, removeEdges } = useVueFlow()
|
|
127
|
-
|
|
128
|
-
onMounted(() => {
|
|
129
|
-
document.removeEventListener('keypress', handleKeypress)
|
|
130
|
-
document.addEventListener('keypress', handleKeypress)
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
onBeforeUnmount(() => {
|
|
134
|
-
document.removeEventListener('keypress', handleKeypress)
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
const setInstance = (instance: VueFlowStore) => {
|
|
138
|
-
vueFlowInstance.value = instance
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
vueFlowElements.value = elements.value
|
|
142
|
-
|
|
143
|
-
// Methods
|
|
144
|
-
const shiftTerminal = (currentTerminal: Position) => {
|
|
145
|
-
return {
|
|
146
|
-
[Position.Top]: Position.Right,
|
|
147
|
-
[Position.Right]: Position.Bottom,
|
|
148
|
-
[Position.Bottom]: Position.Left,
|
|
149
|
-
[Position.Left]: Position.Top,
|
|
150
|
-
}[currentTerminal]
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const shiftOutput = () => {
|
|
154
|
-
if (activeElementIndex.value > -1) {
|
|
155
|
-
const activeNode = vueFlowElements.value[activeElementIndex.value] as Node
|
|
156
|
-
if (!activeNode.sourcePosition) return
|
|
157
|
-
activeNode.sourcePosition = shiftTerminal(activeNode.sourcePosition)
|
|
158
|
-
emitElements()
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const shiftInput = () => {
|
|
163
|
-
if (activeElementIndex.value > -1) {
|
|
164
|
-
const activeNode = vueFlowElements.value[activeElementIndex.value] as Node
|
|
165
|
-
if (!activeNode.targetPosition) return
|
|
166
|
-
activeNode.targetPosition = shiftTerminal(activeNode.targetPosition)
|
|
167
|
-
emitElements()
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const onWheel = (event: WheelEvent) => {
|
|
172
|
-
window.scrollBy(0, event.deltaY)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const handleKeypress = (event: KeyboardEvent) => {
|
|
176
|
-
if (hover.value && event.ctrlKey == true) {
|
|
177
|
-
if (event.key == '+' || event.key == '=') {
|
|
178
|
-
void vueFlowInstance.value?.zoomIn()
|
|
179
|
-
}
|
|
180
|
-
if (event.key == '-') {
|
|
181
|
-
void vueFlowInstance.value?.zoomOut()
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const fitView = async () => {
|
|
187
|
-
await vueFlowInstance.value?.fitView()
|
|
188
|
-
}
|
|
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
|
-
|
|
219
|
-
const addNode = () => {
|
|
220
|
-
let makeEdge = false
|
|
221
|
-
let newNodePosition = { x: Math.random() * 200, y: Math.random() * 200 }
|
|
222
|
-
if (activeElementIndex.value > -1) {
|
|
223
|
-
const activeNode = vueFlowElements.value[activeElementIndex.value]
|
|
224
|
-
if (activeNode.data?.hasOutput) {
|
|
225
|
-
newNodePosition = { x: (activeNode as Node).position.x + 200, y: (activeNode as Node).position.y + 50 }
|
|
226
|
-
makeEdge = true
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const id = vueFlowElements.value.length
|
|
231
|
-
const nodeId = `node-${id}`
|
|
232
|
-
vueFlowElements.value.push({
|
|
233
|
-
id: nodeId,
|
|
234
|
-
label: 'Node ' + id,
|
|
235
|
-
sourcePosition: Position.Right,
|
|
236
|
-
targetPosition: Position.Left,
|
|
237
|
-
class: 'vue-flow__node-default',
|
|
238
|
-
type: 'editable',
|
|
239
|
-
data: {
|
|
240
|
-
hasInput: true,
|
|
241
|
-
hasOutput: true,
|
|
242
|
-
},
|
|
243
|
-
position: newNodePosition,
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
if (makeEdge) {
|
|
247
|
-
const edgeId = `edge-${id + 1}`
|
|
248
|
-
vueFlowElements.value.push({
|
|
249
|
-
id: edgeId,
|
|
250
|
-
source: activeElementKey.value,
|
|
251
|
-
target: nodeId,
|
|
252
|
-
type: 'editable',
|
|
253
|
-
label: `EDGE ${id + 1}`,
|
|
254
|
-
animated: true,
|
|
255
|
-
})
|
|
256
|
-
}
|
|
257
|
-
emitElements()
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const labelChanged = (event: DefaultEdge['label'], id: DefaultEdge['id']) => {
|
|
261
|
-
for (let j = 0; j < vueFlowElements.value.length; j++) {
|
|
262
|
-
if (vueFlowElements.value[j].id == id) {
|
|
263
|
-
vueFlowElements.value[j].label = event
|
|
264
|
-
break
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
emitElements()
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const handleNodeClick = ({ node }: NodeMouseEvent) => {
|
|
271
|
-
activeElementKey.value = node.id
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const handleEdgeClick = ({ edge }: EdgeMouseEvent) => {
|
|
275
|
-
activeElementKey.value = edge.id
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const handleConnect = async (event: Connection) => {
|
|
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
|
|
282
|
-
const newEdge = {
|
|
283
|
-
id: `edge-${id}`,
|
|
284
|
-
source: event.source,
|
|
285
|
-
target: event.target,
|
|
286
|
-
type: isSelfLoop ? 'selfloop' : 'editable',
|
|
287
|
-
label: `New Edge`,
|
|
288
|
-
interactionWidth: 400,
|
|
289
|
-
animated: true,
|
|
290
|
-
}
|
|
291
|
-
addEdges([newEdge])
|
|
292
|
-
await nextTick()
|
|
293
|
-
emitElements()
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const handleEdgeContextMenu = async (event: EdgeMouseEvent) => {
|
|
297
|
-
removeEdges([event.edge.id])
|
|
298
|
-
await nextTick()
|
|
299
|
-
emitElements()
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const removeEdge = async (id: string) => {
|
|
303
|
-
removeEdges([id])
|
|
304
|
-
await nextTick()
|
|
305
|
-
emitElements()
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const emitElements = () => {
|
|
309
|
-
emit('update:modelValue', JSON.parse(JSON.stringify(vueFlowElements.value)))
|
|
310
|
-
}
|
|
311
|
-
</script>
|
|
312
|
-
|
|
313
|
-
<style>
|
|
314
|
-
@import '@vue-flow/core/dist/style.css';
|
|
315
|
-
@import '@vue-flow/core/dist/theme-default.css';
|
|
316
|
-
|
|
317
|
-
.chart-controls-left,
|
|
318
|
-
.chart-controls-right {
|
|
319
|
-
height: 1.8em;
|
|
320
|
-
display: flex;
|
|
321
|
-
flex-direction: row;
|
|
322
|
-
align-items: center;
|
|
323
|
-
padding-top: 0.2em;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
.chart-controls-right div {
|
|
327
|
-
margin-left: 5px;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
.chart-controls {
|
|
331
|
-
position: absolute;
|
|
332
|
-
bottom: 0.5rem;
|
|
333
|
-
left: 0.5rem;
|
|
334
|
-
z-index: 10;
|
|
335
|
-
display: flex;
|
|
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);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
.chart-controls div {
|
|
347
|
-
margin-bottom: 0;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
.defaultContainerClass {
|
|
351
|
-
height: 90vh;
|
|
352
|
-
width: 100%;
|
|
353
|
-
border: 1px solid #ccc;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
.default-input-node.vue-flow__node-input,
|
|
357
|
-
.default-output-node.vue-flow__node-output {
|
|
358
|
-
border-color: #000;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
.default-input-node.vue-flow__node-input .vue-flow__handle,
|
|
362
|
-
.default-output-node.vue-flow__node-output .vue-flow__handle {
|
|
363
|
-
background-color: #000;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
.default-input-node.vue-flow__node-input.selected,
|
|
367
|
-
.default-output-node.vue-flow__node-output.selected {
|
|
368
|
-
box-shadow: 0 0 0 0.5px #000;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
button.button-default {
|
|
372
|
-
background-color: #ffffff;
|
|
373
|
-
padding: 1px 12px;
|
|
374
|
-
border-radius: 3px;
|
|
375
|
-
border: 1px solid #ccc;
|
|
376
|
-
cursor: pointer;
|
|
377
|
-
white-space: nowrap;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
button.button-default:hover {
|
|
381
|
-
background-color: #f2f2f2;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
.vue-flow {
|
|
385
|
-
background-size: 40px 40px;
|
|
386
|
-
background-image:
|
|
387
|
-
linear-gradient(to right, #ccc 1px, transparent 1px), linear-gradient(to bottom, #ccc 1px, transparent 1px);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
input.label-editor {
|
|
391
|
-
position: absolute;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
.node-editor-wrapper {
|
|
395
|
-
position: relative;
|
|
396
|
-
}
|
|
397
|
-
</style>
|
|
@@ -1,88 +0,0 @@
|
|
|
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>
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<div>
|
|
3
|
-
<NodeEditor v-model="elements" :node-container-class="nodeContainerClass" />
|
|
4
|
-
</div>
|
|
5
|
-
</template>
|
|
6
|
-
|
|
7
|
-
<script setup lang="ts">
|
|
8
|
-
import { type HTMLAttributes, computed, onMounted } from 'vue'
|
|
9
|
-
import type { WorkflowMeta } from '@stonecrop/schema'
|
|
10
|
-
|
|
11
|
-
import NodeEditor from './NodeEditor.vue'
|
|
12
|
-
import type { FlowElements, Layout } from '../types'
|
|
13
|
-
import { autoLayout } from '../utils/autoLayout'
|
|
14
|
-
import { statesToFlowElements, flowElementsToStates } from '../utils/stateTransforms'
|
|
15
|
-
|
|
16
|
-
const workflow = defineModel<WorkflowMeta>()
|
|
17
|
-
const layout = defineModel<Layout>('layout')
|
|
18
|
-
const { nodeContainerClass = '' } = defineProps<{
|
|
19
|
-
nodeContainerClass?: HTMLAttributes['class']
|
|
20
|
-
}>()
|
|
21
|
-
|
|
22
|
-
onMounted(() => {
|
|
23
|
-
if (layout.value === undefined) {
|
|
24
|
-
console.warn('[StateEditor] v-model:layout is not bound. Node position changes will not be persisted.')
|
|
25
|
-
}
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
const elements = computed<FlowElements>({
|
|
29
|
-
get: () => {
|
|
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
|
|
37
|
-
},
|
|
38
|
-
set: newValue => {
|
|
39
|
-
const { workflow: nextWorkflow, layout: nextLayout } = flowElementsToStates(newValue, workflow.value)
|
|
40
|
-
workflow.value = nextWorkflow
|
|
41
|
-
if (layout.value !== undefined) {
|
|
42
|
-
layout.value = nextLayout
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
})
|
|
46
|
-
</script>
|
package/src/index.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { App } from 'vue'
|
|
2
|
-
|
|
3
|
-
import NodeEditor from './components/NodeEditor.vue'
|
|
4
|
-
import StateEditor from './components/StateEditor.vue'
|
|
5
|
-
export type * from './types'
|
|
6
|
-
|
|
7
|
-
// `Layout` types its handle placement as this enum, so a consumer cannot fill that field without
|
|
8
|
-
// naming it. It is an enum rather than a string union, which makes the obvious `'left'` a type
|
|
9
|
-
// error, and @vue-flow/core is a transitive dependency a consumer should not have to add.
|
|
10
|
-
export { Position } from '@vue-flow/core'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Install all Node Editor components
|
|
14
|
-
* @param app - Vue app instance
|
|
15
|
-
* @public
|
|
16
|
-
*/
|
|
17
|
-
function install(app: App) {
|
|
18
|
-
app.component('NodeEditor', NodeEditor)
|
|
19
|
-
app.component('StateEditor', StateEditor)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export { install, NodeEditor, StateEditor }
|
package/src/shims-vue.d.ts
DELETED
package/src/types/index.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { type Elements, type Element, type XYPosition, Position } from '@vue-flow/core'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Flow elements
|
|
5
|
-
* @public
|
|
6
|
-
*/
|
|
7
|
-
export type FlowElements = Elements<
|
|
8
|
-
{ 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 }
|
|
12
|
-
>
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Flow element
|
|
16
|
-
* @public
|
|
17
|
-
*/
|
|
18
|
-
export type FlowElement = Element<
|
|
19
|
-
{ hasInput?: boolean; hasOutput?: boolean },
|
|
20
|
-
{ hasInput?: boolean; hasOutput?: boolean; actionKey?: string }
|
|
21
|
-
>
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Node layout
|
|
25
|
-
* @public
|
|
26
|
-
*/
|
|
27
|
-
export type Layout = {
|
|
28
|
-
[key: string]: {
|
|
29
|
-
position?: XYPosition
|
|
30
|
-
targetPosition?: Position
|
|
31
|
-
sourcePosition?: Position
|
|
32
|
-
}
|
|
33
|
-
}
|
package/src/utils/autoLayout.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
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
|
-
}
|