@stonecrop/node-editor 0.2.5
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/README.md +1 -0
- package/dist/node-editor.js +6991 -0
- package/dist/node-editor.js.map +1 -0
- package/dist/node-editor.umd.cjs +21 -0
- package/dist/node-editor.umd.cjs.map +1 -0
- package/dist/style.css +1 -0
- package/package.json +42 -0
- package/src/components/EditableEdge.vue +105 -0
- package/src/components/EditableNode.vue +83 -0
- package/src/components/NodeEditor.vue +330 -0
- package/src/components/StateEditor.vue +122 -0
package/dist/style.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.editable-edge-label{background-color:#fff;position:relative;font-size:12px}.label-input-wrapper{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center}.label-input{text-align:center}.vue-flow{position:relative;width:100%;height:100%;overflow:hidden;z-index:0}.vue-flow__container{position:absolute;height:100%;width:100%;left:0;top:0}.vue-flow__pane{z-index:1}.vue-flow__pane.draggable{cursor:grab}.vue-flow__pane.dragging{cursor:grabbing}.vue-flow__pane.selection{cursor:pointer}.vue-flow__transformationpane{transform-origin:0 0;z-index:2;pointer-events:none}.vue-flow__viewport{z-index:4}.vue-flow__selection{z-index:6}.vue-flow__edge-labels{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.vue-flow__nodesselection-rect:focus,.vue-flow__nodesselection-rect:focus-visible{outline:none}.vue-flow .vue-flow__edges{pointer-events:none;overflow:visible}.vue-flow__edge-path,.vue-flow__connection-path{stroke:#b1b1b7;stroke-width:1;fill:none}.vue-flow__edge{pointer-events:visibleStroke;cursor:pointer}.vue-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.vue-flow__edge.inactive{pointer-events:none}.vue-flow__edge.selected,.vue-flow__edge:focus,.vue-flow__edge:focus-visible{outline:none}.vue-flow__edge.selected .vue-flow__edge-path,.vue-flow__edge:focus .vue-flow__edge-path,.vue-flow__edge:focus-visible .vue-flow__edge-path{stroke:#555}.vue-flow__edge-textwrapper{pointer-events:all}.vue-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.vue-flow__connection{pointer-events:none}.vue-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.vue-flow__connectionline{z-index:1001}.vue-flow__nodes{pointer-events:none;transform-origin:0 0}.vue-flow__node-default,.vue-flow__node-input,.vue-flow__node-output{border-width:1px;border-style:solid;border-color:#bbb}.vue-flow__node-default.selected,.vue-flow__node-default:focus,.vue-flow__node-default:focus-visible,.vue-flow__node-input.selected,.vue-flow__node-input:focus,.vue-flow__node-input:focus-visible,.vue-flow__node-output.selected,.vue-flow__node-output:focus,.vue-flow__node-output:focus-visible{outline:none;border:1px solid #555}.vue-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:grab}.vue-flow__node.dragging{cursor:grabbing}.vue-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.vue-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.vue-flow__nodesselection-rect.dragging{cursor:grabbing}.vue-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px}.vue-flow__handle.connectable{pointer-events:all;cursor:crosshair}.vue-flow__handle-bottom{top:auto;left:50%;bottom:-4px;transform:translate(-50%)}.vue-flow__handle-top{left:50%;top:-4px;transform:translate(-50%)}.vue-flow__handle-left{top:50%;left:-4px;transform:translateY(-50%)}.vue-flow__handle-right{right:-4px;top:50%;transform:translateY(-50%)}.vue-flow__edgeupdater{cursor:move;pointer-events:all}.vue-flow__panel{position:absolute;z-index:5;margin:15px}.vue-flow__panel.top{top:0}.vue-flow__panel.bottom{bottom:0}.vue-flow__panel.left{left:0}.vue-flow__panel.right{right:0}.vue-flow__panel.center{left:50%;transform:translate(-50%)}@keyframes dashdraw{0%{stroke-dashoffset:10}}:root{--vf-node-bg: #fff;--vf-node-text: #222;--vf-connection-path: #b1b1b7;--vf-handle: #555}.vue-flow__edge.updating .vue-flow__edge-path{stroke:#777}.vue-flow__edge-text{font-size:10px}.vue-flow__edge-textbg{fill:#fff}.vue-flow__connection-path{stroke:var(--vf-connection-path)}.vue-flow__node{cursor:grab}.vue-flow__node.selectable:focus,.vue-flow__node.selectable:focus-visible{outline:none}.vue-flow__node-default,.vue-flow__node-input,.vue-flow__node-output{padding:10px;border-radius:3px;width:150px;font-size:12px;text-align:center;border-width:1px;border-style:solid;color:var(--vf-node-text);background-color:var(--vf-node-bg);border-color:var(--vf-node-color)}.vue-flow__node-default.selected,.vue-flow__node-default.selected:hover,.vue-flow__node-input.selected,.vue-flow__node-input.selected:hover,.vue-flow__node-output.selected,.vue-flow__node-output.selected:hover{box-shadow:0 0 0 .5px var(--vf-box-shadow)}.vue-flow__node-default .vue-flow__handle,.vue-flow__node-input .vue-flow__handle,.vue-flow__node-output .vue-flow__handle{background:var(--vf-handle)}.vue-flow__node-default.selectable:hover,.vue-flow__node-input.selectable:hover,.vue-flow__node-output.selectable:hover{box-shadow:0 1px 4px 1px #00000014}.vue-flow__node-input{--vf-node-color: var(--vf-node-color, #0041d0);--vf-handle: var(--vf-node-color, #0041d0);--vf-box-shadow: var(--vf-node-color, #0041d0);background:var(--vf-node-bg);border-color:var(--vf-node-color, #0041d0)}.vue-flow__node-input.selected,.vue-flow__node-input:focus,.vue-flow__node-input:focus-visible{outline:none;border:1px solid var(--vf-node-color, #0041d0)}.vue-flow__node-default{--vf-handle: var(--vf-node-color, #1a192b);--vf-box-shadow: var(--vf-node-color, #1a192b);background:var(--vf-node-bg);border-color:var(--vf-node-color, #1a192b)}.vue-flow__node-default.selected,.vue-flow__node-default:focus,.vue-flow__node-default:focus-visible{outline:none;border:1px solid var(--vf-node-color, #1a192b)}.vue-flow__node-output{--vf-handle: var(--vf-node-color, #ff0072);--vf-box-shadow: var(--vf-node-color, #ff0072);background:var(--vf-node-bg);border-color:var(--vf-node-color, #ff0072)}.vue-flow__node-output.selected,.vue-flow__node-output:focus,.vue-flow__node-output:focus-visible{outline:none;border:1px solid var(--vf-node-color, #ff0072)}.vue-flow__nodesselection-rect,.vue-flow__selection{background:rgba(0,89,220,.08);border:1px dotted rgba(0,89,220,.8)}.vue-flow__nodesselection-rect:focus,.vue-flow__nodesselection-rect:focus-visible,.vue-flow__selection:focus,.vue-flow__selection:focus-visible{outline:none}.vue-flow__handle{width:6px;height:6px;background:var(--vf-handle);border:1px solid #fff;border-radius:100%}.chart-controls-left,.chart-controls-right{height:1.8em;display:flex;flex-direction:row;align-items:center;padding-top:.2em}.chart-controls-right div{margin-left:5px}.chart-controls{padding-left:20px;padding-right:20px;height:1.8em;border-bottom:1px solid #ccc;display:flex;flex-direction:row;justify-content:space-between}.chart-controls div{margin-bottom:5px}.defaultContainerClass{height:90vh;width:100%;border:1px solid #ccc}.default-input-node.vue-flow__node-input,.default-output-node.vue-flow__node-output{border-color:#000}.default-input-node.vue-flow__node-input .vue-flow__handle,.default-output-node.vue-flow__node-output .vue-flow__handle{background-color:#000}.default-input-node.vue-flow__node-input.selected,.default-output-node.vue-flow__node-output.selected{box-shadow:0 0 0 .5px #000}button.button-default{background-color:#fff;padding:1px 12px;border-radius:3px;border:1px solid #ccc;cursor:pointer;white-space:nowrap}button.button-default:hover{background-color:#f2f2f2}.vue-flow{background-size:40px 40px;background-image:linear-gradient(to right,#ccc 1px,transparent 1px),linear-gradient(to bottom,#ccc 1px,transparent 1px)}input.label-editor{position:absolute}.node-editor-wrapper{position:relative}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stonecrop/node-editor",
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "Node editor UI for Stonecrop",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/node-editor.js",
|
|
10
|
+
"require": "./dist/node-editor.umd.cjs"
|
|
11
|
+
},
|
|
12
|
+
"./styles": "./dist/style.css"
|
|
13
|
+
},
|
|
14
|
+
"main": "index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/*",
|
|
17
|
+
"src/**/*.vue"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@vue-flow/core": "~1.19.3",
|
|
21
|
+
"vue": "^3.2.47",
|
|
22
|
+
"vue-router": "^4",
|
|
23
|
+
"xstate": "~4.37.2"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@histoire/plugin-vue": "^0.16.1",
|
|
27
|
+
"@vitejs/plugin-vue": "^4.2.1",
|
|
28
|
+
"histoire": "^0.16.1",
|
|
29
|
+
"vite": "^4.3.5"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20.11.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "vite build",
|
|
36
|
+
"dev": "vite",
|
|
37
|
+
"preview": "vite preview",
|
|
38
|
+
"story:build": "histoire build",
|
|
39
|
+
"story:dev": "histoire dev",
|
|
40
|
+
"story:preview": "histoire preview"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { EdgeProps, Position } from '@vue-flow/core'
|
|
3
|
+
import { EdgeLabelRenderer, getBezierPath, useVueFlow } from '@vue-flow/core'
|
|
4
|
+
import type { CSSProperties } from 'vue'
|
|
5
|
+
import { computed, ref, nextTick } from 'vue'
|
|
6
|
+
|
|
7
|
+
interface EditableEdgeProps<T = any> extends EdgeProps<T> {
|
|
8
|
+
id: string
|
|
9
|
+
sourceX: number
|
|
10
|
+
sourceY: number
|
|
11
|
+
targetX: number
|
|
12
|
+
targetY: number
|
|
13
|
+
sourcePosition: Position
|
|
14
|
+
targetPosition: Position
|
|
15
|
+
data: T
|
|
16
|
+
markerEnd: string
|
|
17
|
+
style: CSSProperties
|
|
18
|
+
label: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const props = defineProps<EditableEdgeProps>()
|
|
22
|
+
|
|
23
|
+
const { removeEdges } = useVueFlow()
|
|
24
|
+
|
|
25
|
+
const emit = defineEmits(['change'])
|
|
26
|
+
|
|
27
|
+
const labelInput = ref()
|
|
28
|
+
const newLabel = ref('')
|
|
29
|
+
const showInput = ref(false)
|
|
30
|
+
let lastClick = 0
|
|
31
|
+
|
|
32
|
+
const labelOnClick = () => {
|
|
33
|
+
let now = Date.now()
|
|
34
|
+
if (now - lastClick < 500 && !showInput.value) {
|
|
35
|
+
showLabelInput()
|
|
36
|
+
}
|
|
37
|
+
lastClick = now
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const showLabelInput = async () => {
|
|
41
|
+
newLabel.value = props.label
|
|
42
|
+
showInput.value = true
|
|
43
|
+
await nextTick()
|
|
44
|
+
labelInput.value.focus()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const submitNewLabel = () => {
|
|
48
|
+
showInput.value = false
|
|
49
|
+
emit('change', newLabel.value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const path = computed(() => getBezierPath(props))
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<script lang="ts">
|
|
56
|
+
export default {
|
|
57
|
+
inheritAttrs: false,
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<path :id="id" :style="style" class="vue-flow__edge-path" :d="path[0]" :marker-end="markerEnd" />
|
|
63
|
+
|
|
64
|
+
<EdgeLabelRenderer>
|
|
65
|
+
<div
|
|
66
|
+
:style="{
|
|
67
|
+
pointerEvents: 'all',
|
|
68
|
+
position: 'absolute',
|
|
69
|
+
transform: `translate(-50%, -50%) translate(${path[1]}px,${path[2]}px)`,
|
|
70
|
+
}"
|
|
71
|
+
class="nodrag nopan editable-edge-label"
|
|
72
|
+
@click="labelOnClick()">
|
|
73
|
+
<div class="vue-flow__edge-label">{{ props.label }}</div>
|
|
74
|
+
<div v-if="showInput" class="label-input-wrapper">
|
|
75
|
+
<input
|
|
76
|
+
ref="labelInput"
|
|
77
|
+
class="label-input"
|
|
78
|
+
v-model="newLabel"
|
|
79
|
+
@blur="showInput = false"
|
|
80
|
+
@keypress.enter="submitNewLabel" />
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</EdgeLabelRenderer>
|
|
84
|
+
</template>
|
|
85
|
+
|
|
86
|
+
<style>
|
|
87
|
+
.editable-edge-label {
|
|
88
|
+
background-color: white;
|
|
89
|
+
position: relative;
|
|
90
|
+
font-size: 12px;
|
|
91
|
+
}
|
|
92
|
+
.label-input-wrapper {
|
|
93
|
+
position: absolute;
|
|
94
|
+
top: 0;
|
|
95
|
+
left: 0;
|
|
96
|
+
right: 0;
|
|
97
|
+
bottom: 0;
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
}
|
|
102
|
+
.label-input {
|
|
103
|
+
text-align: center;
|
|
104
|
+
}
|
|
105
|
+
</style>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { NodeProps, Handle, Position, NodeEventsOn } from '@vue-flow/core'
|
|
3
|
+
import { computed, ref, nextTick } from 'vue'
|
|
4
|
+
|
|
5
|
+
interface EditableNodeProps extends NodeProps {
|
|
6
|
+
id: string
|
|
7
|
+
label: string
|
|
8
|
+
sourcePosition: Position
|
|
9
|
+
targetPosition: Position
|
|
10
|
+
data: any
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = defineProps<EditableNodeProps>()
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits(['change'])
|
|
16
|
+
|
|
17
|
+
const positionMap = {
|
|
18
|
+
top: Position.Top,
|
|
19
|
+
right: Position.Right,
|
|
20
|
+
bottom: Position.Bottom,
|
|
21
|
+
left: Position.Left,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const sourcePosition = computed(() => positionMap[props.sourcePosition])
|
|
25
|
+
const targetPosition = computed(() => positionMap[props.targetPosition])
|
|
26
|
+
|
|
27
|
+
const labelInput = ref()
|
|
28
|
+
const newLabel = ref('')
|
|
29
|
+
const showInput = ref(false)
|
|
30
|
+
let lastClick = 0
|
|
31
|
+
|
|
32
|
+
const nodeOnClick = () => {
|
|
33
|
+
let now = Date.now()
|
|
34
|
+
if (now - lastClick < 500 && !showInput.value) {
|
|
35
|
+
showLabelInput()
|
|
36
|
+
}
|
|
37
|
+
lastClick = now
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const showLabelInput = async () => {
|
|
41
|
+
newLabel.value = props.label
|
|
42
|
+
showInput.value = true
|
|
43
|
+
await nextTick()
|
|
44
|
+
labelInput.value.focus()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const submitNewLabel = () => {
|
|
48
|
+
showInput.value = false
|
|
49
|
+
emit('change', newLabel.value)
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<div @click="nodeOnClick()">
|
|
55
|
+
<div>{{ props.label }}</div>
|
|
56
|
+
<div v-if="showInput" class="label-input-wrapper">
|
|
57
|
+
<input
|
|
58
|
+
ref="labelInput"
|
|
59
|
+
class="label-input"
|
|
60
|
+
v-model="newLabel"
|
|
61
|
+
@blur="showInput = false"
|
|
62
|
+
@keypress.enter="submitNewLabel" />
|
|
63
|
+
</div>
|
|
64
|
+
<Handle v-if="props.data.hasInput" id="a" type="target" :position="targetPosition" />
|
|
65
|
+
<Handle v-if="props.data.hasOutput" id="b" type="source" :position="sourcePosition" />
|
|
66
|
+
</div>
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<style>
|
|
70
|
+
.label-input-wrapper {
|
|
71
|
+
position: absolute;
|
|
72
|
+
top: 0;
|
|
73
|
+
left: 0;
|
|
74
|
+
right: 0;
|
|
75
|
+
bottom: 0;
|
|
76
|
+
display: flex;
|
|
77
|
+
align-items: center;
|
|
78
|
+
justify-content: center;
|
|
79
|
+
}
|
|
80
|
+
.label-input {
|
|
81
|
+
text-align: center;
|
|
82
|
+
}
|
|
83
|
+
</style>
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="node-editor-wrapper" :class="containerClass" @mouseover="hover = true" @mouseleave="hover = false">
|
|
3
|
+
<div class="chart-controls">
|
|
4
|
+
<div class="chart-controls-left">
|
|
5
|
+
<div><b>Selected Node:</b> {{ activeElementKey ? activeElementKey : 'none' }}</div>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="chart-controls-right">
|
|
8
|
+
<div>
|
|
9
|
+
<button class="button-default" @click="addNode()">Add Node</button>
|
|
10
|
+
</div>
|
|
11
|
+
<div>
|
|
12
|
+
<button class="button-default" @click="fitView()">Center</button>
|
|
13
|
+
</div>
|
|
14
|
+
<div v-if="activeElementIndex > -1">
|
|
15
|
+
<button class="button-default" @click="shiftInput()">Shift Input Position</button>
|
|
16
|
+
</div>
|
|
17
|
+
<div v-if="activeElementIndex > -1">
|
|
18
|
+
<button class="button-default" @click="shiftOutput()">Shift Output Position</button>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<VueFlow
|
|
24
|
+
@wheel.prevent="onWheel($event)"
|
|
25
|
+
class="nowheel"
|
|
26
|
+
:prevent-scrolling="true"
|
|
27
|
+
:zoom-on-scroll="false"
|
|
28
|
+
:fit-view-on-init="true"
|
|
29
|
+
v-if="vueFlowElements && vueFlowElements.length"
|
|
30
|
+
v-model="vueFlowElements"
|
|
31
|
+
@connect="onConnect($event)"
|
|
32
|
+
@edge-double-click="onEdgeDoubleClick($event)">
|
|
33
|
+
<template #node-editable="props">
|
|
34
|
+
<EditableNode v-bind="props" @change="labelChanged($event, props.id)" />
|
|
35
|
+
</template>
|
|
36
|
+
<template #edge-editable="props">
|
|
37
|
+
<EditableEdge v-bind="props" @change="labelChanged($event, props.id)" />
|
|
38
|
+
</template>
|
|
39
|
+
</VueFlow>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
42
|
+
<script lang="ts" setup>
|
|
43
|
+
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
|
44
|
+
import '@vue-flow/core/dist/style.css'
|
|
45
|
+
import '@vue-flow/core/dist/theme-default.css'
|
|
46
|
+
import EditableNode from './EditableNode.vue'
|
|
47
|
+
import EditableEdge from './EditableEdge.vue'
|
|
48
|
+
import { ref, computed, defineEmits, onBeforeUnmount, onMounted, watch, reactive } from 'vue'
|
|
49
|
+
|
|
50
|
+
// Props
|
|
51
|
+
|
|
52
|
+
const props = defineProps<{
|
|
53
|
+
modelValue: any
|
|
54
|
+
nodeContainerClass: string
|
|
55
|
+
}>()
|
|
56
|
+
|
|
57
|
+
// Emits
|
|
58
|
+
|
|
59
|
+
const emit = defineEmits(['update:modelValue'])
|
|
60
|
+
|
|
61
|
+
// Data
|
|
62
|
+
|
|
63
|
+
const containerClass = ref('')
|
|
64
|
+
const vueFlowInstance = ref({})
|
|
65
|
+
const hover = ref(false)
|
|
66
|
+
const labelEditor = ref({
|
|
67
|
+
x: 0,
|
|
68
|
+
y: 0,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const activeElementKey = ref('')
|
|
72
|
+
|
|
73
|
+
const vueFlowElements = ref([])
|
|
74
|
+
|
|
75
|
+
// Computed variables
|
|
76
|
+
|
|
77
|
+
const activeElementIndex = computed(() => {
|
|
78
|
+
for (let j = 0; j < vueFlowElements.value.length; j++) {
|
|
79
|
+
if (vueFlowElements.value[j].id == activeElementKey.value) return j
|
|
80
|
+
}
|
|
81
|
+
return -1
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const elements = computed({
|
|
85
|
+
get: () => {
|
|
86
|
+
let _elements = props.modelValue
|
|
87
|
+
if (props.nodeContainerClass) {
|
|
88
|
+
containerClass.value = props.nodeContainerClass
|
|
89
|
+
} else {
|
|
90
|
+
containerClass.value = 'defaultContainerClass'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (let j = 0; j < _elements.length; j++) {
|
|
94
|
+
_elements[j].data = {}
|
|
95
|
+
if (_elements[j].type == 'input') {
|
|
96
|
+
_elements[j].data.hasInput = false
|
|
97
|
+
_elements[j].data.hasOutput = true
|
|
98
|
+
} else if (_elements[j].type == 'output') {
|
|
99
|
+
_elements[j].data.hasInput = true
|
|
100
|
+
_elements[j].data.hasOutput = false
|
|
101
|
+
} else {
|
|
102
|
+
_elements[j].data.hasInput = true
|
|
103
|
+
_elements[j].data.hasOutput = true
|
|
104
|
+
}
|
|
105
|
+
_elements[j].class = 'vue-flow__node-default'
|
|
106
|
+
_elements[j].type = 'editable'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (let j = 0; j < _elements.length; j++) {
|
|
110
|
+
let key = _elements[j].id
|
|
111
|
+
let el = _elements[j]
|
|
112
|
+
_elements[j].events = {
|
|
113
|
+
click: () => {
|
|
114
|
+
activeElementKey.value = key
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return _elements
|
|
120
|
+
},
|
|
121
|
+
set: newValue => {
|
|
122
|
+
emit('update:modelValue', JSON.parse(JSON.stringify(newValue)))
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
//VueFlow
|
|
127
|
+
|
|
128
|
+
const { getNodes, onPaneReady } = useVueFlow({})
|
|
129
|
+
|
|
130
|
+
onPaneReady(i => {
|
|
131
|
+
vueFlowInstance.value = i
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Setup
|
|
135
|
+
|
|
136
|
+
vueFlowElements.value = elements.value
|
|
137
|
+
|
|
138
|
+
// Lifecycle Hooks
|
|
139
|
+
|
|
140
|
+
onMounted(() => {
|
|
141
|
+
document.removeEventListener('keypress', handleKeypress)
|
|
142
|
+
document.addEventListener('keypress', handleKeypress)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
onBeforeUnmount(() => {
|
|
146
|
+
document.removeEventListener('keypress', handleKeypress)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Methods
|
|
150
|
+
|
|
151
|
+
const shiftTerminal = currentTerminal => {
|
|
152
|
+
return {
|
|
153
|
+
top: 'right',
|
|
154
|
+
right: 'bottom',
|
|
155
|
+
bottom: 'left',
|
|
156
|
+
left: 'top',
|
|
157
|
+
}[currentTerminal]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const shiftOutput = () => {
|
|
161
|
+
if (activeElementIndex.value > -1) {
|
|
162
|
+
vueFlowElements.value[activeElementIndex.value].sourcePosition = shiftTerminal(
|
|
163
|
+
vueFlowElements.value[activeElementIndex.value].sourcePosition
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const shiftInput = () => {
|
|
169
|
+
if (activeElementIndex.value > -1) {
|
|
170
|
+
vueFlowElements.value[activeElementIndex.value].targetPosition = shiftTerminal(
|
|
171
|
+
vueFlowElements.value[activeElementIndex.value].targetPosition
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const onWheel = $event => {
|
|
177
|
+
window.scrollBy(0, $event.deltaY)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const handleKeypress = e => {
|
|
181
|
+
if (hover.value && e.ctrlKey == true) {
|
|
182
|
+
if (e.key == '+' || e.key == '=') {
|
|
183
|
+
vueFlowInstance.value.zoomIn()
|
|
184
|
+
}
|
|
185
|
+
if (e.key == '-') {
|
|
186
|
+
vueFlowInstance.value.zoomOut()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const fitView = () => {
|
|
192
|
+
vueFlowInstance.value.fitView()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const addNode = () => {
|
|
196
|
+
let newNodePosition = { x: Math.random() * 200, y: Math.random() * 200 }
|
|
197
|
+
let makeEdge = false
|
|
198
|
+
if (activeElementIndex.value > -1) {
|
|
199
|
+
const activeNode = vueFlowElements.value[activeElementIndex.value]
|
|
200
|
+
if (activeNode.data.hasOutput) {
|
|
201
|
+
newNodePosition = { x: activeNode.position.x + 200, y: activeNode.position.y + 50 }
|
|
202
|
+
makeEdge = true
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let id = vueFlowElements.value.length
|
|
207
|
+
let nodeId = `node-${id}`
|
|
208
|
+
vueFlowElements.value.push({
|
|
209
|
+
id: nodeId,
|
|
210
|
+
label: 'Node ' + id,
|
|
211
|
+
sourcePosition: 'right',
|
|
212
|
+
targetPosition: 'left',
|
|
213
|
+
class: 'vue-flow__node-default',
|
|
214
|
+
type: 'editable',
|
|
215
|
+
data: {
|
|
216
|
+
hasInput: true,
|
|
217
|
+
hasOutput: true,
|
|
218
|
+
},
|
|
219
|
+
events: {
|
|
220
|
+
click: () => {
|
|
221
|
+
activeElementKey.value = nodeId
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
// position: { x: Math.random() * vueFlowInstance.value.dimensions.width, y: Math.random() * vueFlowInstance.value.dimensions.height }
|
|
225
|
+
position: newNodePosition,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
if (makeEdge) {
|
|
229
|
+
let edgeId = `edge-${id + 1}`
|
|
230
|
+
vueFlowElements.value.push({
|
|
231
|
+
id: edgeId,
|
|
232
|
+
source: activeElementKey.value,
|
|
233
|
+
target: nodeId,
|
|
234
|
+
type: 'editable',
|
|
235
|
+
label: `EDGE ${id + 1}`,
|
|
236
|
+
animated: true,
|
|
237
|
+
events: {
|
|
238
|
+
click: () => {
|
|
239
|
+
activeElementKey.value = edgeId
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const onConnect = e => {
|
|
247
|
+
console.log('edge connect', e)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const onEdgeDoubleClick = e => {
|
|
251
|
+
console.log('edge double click', e)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const labelChanged = (e, id) => {
|
|
255
|
+
for (let j = 0; j < vueFlowElements.value.length; j++) {
|
|
256
|
+
if (vueFlowElements.value[j].id == id) {
|
|
257
|
+
vueFlowElements.value[j].label = e
|
|
258
|
+
break
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
</script>
|
|
263
|
+
<style>
|
|
264
|
+
@import '@vue-flow/core/dist/style.css';
|
|
265
|
+
@import '@vue-flow/core/dist/theme-default.css';
|
|
266
|
+
|
|
267
|
+
.chart-controls-left,
|
|
268
|
+
.chart-controls-right {
|
|
269
|
+
height: 1.8em;
|
|
270
|
+
display: flex;
|
|
271
|
+
flex-direction: row;
|
|
272
|
+
align-items: center;
|
|
273
|
+
padding-top: 0.2em;
|
|
274
|
+
}
|
|
275
|
+
.chart-controls-right div {
|
|
276
|
+
margin-left: 5px;
|
|
277
|
+
}
|
|
278
|
+
.chart-controls {
|
|
279
|
+
padding-left: 20px;
|
|
280
|
+
padding-right: 20px;
|
|
281
|
+
height: 1.8em;
|
|
282
|
+
border-bottom: 1px solid #ccc;
|
|
283
|
+
display: flex;
|
|
284
|
+
flex-direction: row;
|
|
285
|
+
justify-content: space-between;
|
|
286
|
+
}
|
|
287
|
+
.chart-controls div {
|
|
288
|
+
margin-bottom: 5px;
|
|
289
|
+
}
|
|
290
|
+
.defaultContainerClass {
|
|
291
|
+
height: 90vh;
|
|
292
|
+
width: 100%;
|
|
293
|
+
border: 1px solid #ccc;
|
|
294
|
+
}
|
|
295
|
+
.default-input-node.vue-flow__node-input,
|
|
296
|
+
.default-output-node.vue-flow__node-output {
|
|
297
|
+
border-color: #000;
|
|
298
|
+
}
|
|
299
|
+
.default-input-node.vue-flow__node-input .vue-flow__handle,
|
|
300
|
+
.default-output-node.vue-flow__node-output .vue-flow__handle {
|
|
301
|
+
background-color: #000;
|
|
302
|
+
}
|
|
303
|
+
.default-input-node.vue-flow__node-input.selected,
|
|
304
|
+
.default-output-node.vue-flow__node-output.selected {
|
|
305
|
+
box-shadow: 0 0 0 0.5px #000;
|
|
306
|
+
}
|
|
307
|
+
button.button-default {
|
|
308
|
+
background-color: #ffffff;
|
|
309
|
+
padding: 1px 12px;
|
|
310
|
+
border-radius: 3px;
|
|
311
|
+
border: 1px solid #ccc;
|
|
312
|
+
cursor: pointer;
|
|
313
|
+
white-space: nowrap;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
button.button-default:hover {
|
|
317
|
+
background-color: #f2f2f2;
|
|
318
|
+
}
|
|
319
|
+
.vue-flow {
|
|
320
|
+
background-size: 40px 40px;
|
|
321
|
+
background-image: linear-gradient(to right, #ccc 1px, transparent 1px),
|
|
322
|
+
linear-gradient(to bottom, #ccc 1px, transparent 1px);
|
|
323
|
+
}
|
|
324
|
+
input.label-editor {
|
|
325
|
+
position: absolute;
|
|
326
|
+
}
|
|
327
|
+
.node-editor-wrapper {
|
|
328
|
+
position: relative;
|
|
329
|
+
}
|
|
330
|
+
</style>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<NodeEditor v-model="elements" :node-container-class="nodeContainerClass" />
|
|
4
|
+
</div>
|
|
5
|
+
</template>
|
|
6
|
+
<script lang="ts" setup>
|
|
7
|
+
import { VueFlow } from '@vue-flow/core'
|
|
8
|
+
import '@vue-flow/core/dist/style.css'
|
|
9
|
+
import '@vue-flow/core/dist/theme-default.css'
|
|
10
|
+
import NodeEditor from './NodeEditor.vue'
|
|
11
|
+
import { ref, computed } from 'vue'
|
|
12
|
+
|
|
13
|
+
// Props
|
|
14
|
+
|
|
15
|
+
const props = defineProps(['layout', 'nodeContainerClass', 'modelValue'])
|
|
16
|
+
|
|
17
|
+
// Emits
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits(['update:modelValue'])
|
|
20
|
+
|
|
21
|
+
// Computed variables
|
|
22
|
+
|
|
23
|
+
const elements = computed({
|
|
24
|
+
get: () => {
|
|
25
|
+
let states = props.modelValue
|
|
26
|
+
let stateHash = {}
|
|
27
|
+
let hasInputs = {}
|
|
28
|
+
let j = 0
|
|
29
|
+
let stateElements = []
|
|
30
|
+
for (let key in states) {
|
|
31
|
+
let idx = stateElements.length
|
|
32
|
+
let el = {
|
|
33
|
+
id: key,
|
|
34
|
+
label: key,
|
|
35
|
+
position: props.layout[key] && props.layout[key].position ? props.layout[key].position : { x: 200 * j, y: 100 },
|
|
36
|
+
targetPosition:
|
|
37
|
+
props.layout[key] && props.layout[key].targetPosition ? props.layout[key].targetPosition : 'left',
|
|
38
|
+
sourcePosition:
|
|
39
|
+
props.layout[key] && props.layout[key].sourcePosition ? props.layout[key].sourcePosition : 'right',
|
|
40
|
+
}
|
|
41
|
+
if (states[key].type && states[key].type == 'final') {
|
|
42
|
+
el.type = 'output'
|
|
43
|
+
el.class = 'default-output-node'
|
|
44
|
+
}
|
|
45
|
+
stateHash[key] = el
|
|
46
|
+
let edges = states[key].on
|
|
47
|
+
for (let edgeKey in states[key].on) {
|
|
48
|
+
let target = edges[edgeKey]
|
|
49
|
+
if (typeof target === 'object' && target.constructor === Object) {
|
|
50
|
+
target = target.target
|
|
51
|
+
}
|
|
52
|
+
stateElements.push({
|
|
53
|
+
id: `${key}-${edges[edgeKey]}-${edgeKey}`,
|
|
54
|
+
target: target,
|
|
55
|
+
source: key,
|
|
56
|
+
label: edgeKey,
|
|
57
|
+
animated: true,
|
|
58
|
+
})
|
|
59
|
+
hasInputs[target] = true
|
|
60
|
+
}
|
|
61
|
+
j++
|
|
62
|
+
}
|
|
63
|
+
for (let key in stateHash) {
|
|
64
|
+
if (!hasInputs[key]) {
|
|
65
|
+
stateHash[key]['type'] = 'input'
|
|
66
|
+
stateHash[key]['class'] = 'default-input-node'
|
|
67
|
+
}
|
|
68
|
+
stateElements.push(stateHash[key])
|
|
69
|
+
}
|
|
70
|
+
return stateElements
|
|
71
|
+
},
|
|
72
|
+
set: newValue => {
|
|
73
|
+
// update modelValue when elements change
|
|
74
|
+
onElementsChange(newValue)
|
|
75
|
+
// emit('update:modelValue', props.modelValue)
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Methods
|
|
80
|
+
|
|
81
|
+
const onElementsChange = elements => {
|
|
82
|
+
let states = {}
|
|
83
|
+
let edges = {}
|
|
84
|
+
let idToLabel = {}
|
|
85
|
+
for (let i = 0; i < elements.length; i++) {
|
|
86
|
+
let el = elements[i]
|
|
87
|
+
if (el.type == 'input') {
|
|
88
|
+
// it's an input node
|
|
89
|
+
states[el.label] = {
|
|
90
|
+
on: {},
|
|
91
|
+
}
|
|
92
|
+
} else if (el.type == 'output') {
|
|
93
|
+
// it's an output node
|
|
94
|
+
states[el.label] = {
|
|
95
|
+
type: 'final',
|
|
96
|
+
}
|
|
97
|
+
} else if (el.source && el.target) {
|
|
98
|
+
// it's an edge
|
|
99
|
+
edges[el.source] = edges[el.source] || {}
|
|
100
|
+
edges[el.source][el.label] = {
|
|
101
|
+
target: el.target,
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// it's a state
|
|
105
|
+
states[el.label] = {
|
|
106
|
+
on: {},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
idToLabel[el.id] = el.label
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (let key in edges) {
|
|
113
|
+
// add edges to states
|
|
114
|
+
let label = idToLabel[key]
|
|
115
|
+
for (let edgeKey in edges[key]) {
|
|
116
|
+
states[label].on[edgeKey] = edges[key][edgeKey]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
emit('update:modelValue', states)
|
|
120
|
+
}
|
|
121
|
+
</script>
|
|
122
|
+
<style scoped></style>
|