Add full graph editor controls

This commit is contained in:
2026-06-19 01:14:17 +02:00
parent 1068f7e659
commit 7927b34c5f
3 changed files with 421 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import '@vue-flow/controls/dist/style.css'
import GraphFxNode from './components/GraphFxNode.vue' import GraphFxNode from './components/GraphFxNode.vue'
import { countSavedPositions, graphFxToVueFlow, parseGraphFxJson, type GraphFxFlowWarning, type GraphFxSerializedGraph } from './lib/graphfxToVueFlow' import { countSavedPositions, graphFxToVueFlow, parseGraphFxJson, type GraphFxFlowWarning, type GraphFxSerializedGraph } from './lib/graphfxToVueFlow'
import { layoutGraph } from './lib/layout' import { layoutGraph } from './lib/layout'
import { coerceIoValue, createSerializedNode, displayIoValue, nodeCatalog, removeNodeAndConnections } from './lib/editorHelpers'
import { sampleGraph } from './lib/sampleGraph' import { sampleGraph } from './lib/sampleGraph'
import apiExampleJson from '../examples/api.json?raw' import apiExampleJson from '../examples/api.json?raw'
import { import {
@@ -32,6 +33,8 @@ const selectedSaveSlot = ref(localStorage.getItem('GRAPHFX_UI_SELECTED_SAVE_SLOT
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const activeTab = ref<'control' | 'data'>('control') const activeTab = ref<'control' | 'data'>('control')
const selectedNodeId = ref<string | null>(null)
const nodeSearch = ref('')
const { fitView } = useVueFlow() const { fitView } = useVueFlow()
const runtimeState = ref<RuntimeState | null>(null) const runtimeState = ref<RuntimeState | null>(null)
@@ -49,6 +52,13 @@ const liveFrames = new Map<string, number>()
const labeledImageInputs = computed(() => runtimeState.value?.inputs || []) const labeledImageInputs = computed(() => runtimeState.value?.inputs || [])
const labeledImageOutputs = computed(() => runtimeState.value?.outputs || []) const labeledImageOutputs = computed(() => runtimeState.value?.outputs || [])
const selectedGraphNode = computed(() => graph.value.find((item) => item.node.id === selectedNodeId.value) || null)
const selectedFlowNode = computed(() => nodes.value.find((node) => node.id === selectedNodeId.value) || null)
const filteredNodeCatalog = computed(() => {
const query = nodeSearch.value.trim().toLowerCase()
return nodeCatalog.filter((item) => !query || item.name.toLowerCase().includes(query)).slice(0, 80)
})
const firstCatalogMatch = computed(() => filteredNodeCatalog.value[0] || null)
const stats = computed(() => ({ const stats = computed(() => ({
nodes: nodes.value.length, nodes: nodes.value.length,
@@ -62,6 +72,7 @@ function refreshFlow() {
const parsed = parseGraphFxJson(editorText.value) const parsed = parseGraphFxJson(editorText.value)
const model = graphFxToVueFlow(parsed) const model = graphFxToVueFlow(parsed)
graph.value = parsed graph.value = parsed
if (selectedNodeId.value && !parsed.some((item) => item.node.id === selectedNodeId.value)) selectedNodeId.value = null
nodes.value = layoutGraph(model.nodes, model.edges, { nodes.value = layoutGraph(model.nodes, model.edges, {
direction: 'LR', direction: 'LR',
useSavedPositions: useSavedPositions.value, useSavedPositions: useSavedPositions.value,
@@ -79,6 +90,106 @@ function syncEditorFromGraph() {
editorText.value = JSON.stringify(graph.value, null, 2) editorText.value = JSON.stringify(graph.value, null, 2)
} }
function refreshFromGraph(options: { fit?: boolean } = {}) {
syncEditorFromGraph()
refreshFlow()
if (options.fit) nextTick(() => fitView({ padding: 0.18, duration: 250 }))
}
function defaultNewNodePosition() {
const maxX = Math.max(0, ...graph.value.map((item) => typeof item.x === 'number' ? item.x : 0))
const maxY = Math.max(0, ...graph.value.map((item) => typeof item.y === 'number' ? item.y : 0))
return { x: maxX + 340, y: Math.max(40, maxY + 40) }
}
function addNode(name: string) {
try {
const node = createSerializedNode(name, defaultNewNodePosition())
graph.value = [...graph.value, node]
selectedNodeId.value = node.node.id
refreshFromGraph({ fit: true })
activeTab.value = 'control'
} catch (error) {
parseError.value = error instanceof Error ? error.message : String(error)
}
}
function addFirstCatalogMatch() {
if (firstCatalogMatch.value) addNode(firstCatalogMatch.value.name)
}
function deleteSelectedNode() {
if (!selectedNodeId.value) return
graph.value = removeNodeAndConnections(graph.value, selectedNodeId.value)
selectedNodeId.value = null
refreshFromGraph()
}
function duplicateSelectedNode() {
const source = selectedGraphNode.value
if (!source) return
const clone = JSON.parse(JSON.stringify(source)) as typeof source
clone.node.id = crypto.randomUUID()
clone.x = (typeof source.x === 'number' ? source.x : 0) + 60
clone.y = (typeof source.y === 'number' ? source.y : 0) + 60
for (const input of Object.values(clone.node.options?.in || {})) input.output = null
graph.value = [...graph.value, clone]
selectedNodeId.value = clone.node.id
refreshFromGraph({ fit: true })
}
function renameSelectedNodeId(nextId: string) {
const item = selectedGraphNode.value
const oldId = item?.node.id
const clean = nextId.trim()
if (!item || !oldId || !clean || clean === oldId) return
if (graph.value.some((entry) => entry.node.id === clean)) {
parseError.value = `A node with id "${clean}" already exists.`
return
}
item.node.id = clean
const oldPrefix = `${oldId}-out-`
for (const entry of graph.value) {
for (const input of Object.values(entry.node.options?.in || {})) {
if (input.output?.startsWith(oldPrefix)) input.output = input.output.replace(oldPrefix, `${clean}-out-`)
}
}
selectedNodeId.value = clean
refreshFromGraph()
}
function updateIoLabel(direction: 'in' | 'out', ioName: string, label: string) {
const io = selectedGraphNode.value?.node.options?.[direction]?.[ioName]
if (!io) return
io.label = label.trim() || null
refreshFromGraph()
}
function updateInputValue(ioName: string, rawValue: string) {
const input = selectedGraphNode.value?.node.options?.in?.[ioName]
const port = selectedFlowNode.value?.data?.inputs?.find((item) => item.name === ioName)
if (!input || !port) return
input.value = coerceIoValue(rawValue, port.type)
input.output = null
refreshFromGraph()
}
function disconnectInput(ioName: string) {
const input = selectedGraphNode.value?.node.options?.in?.[ioName]
if (!input) return
input.output = null
refreshFromGraph()
}
function clearSelection() {
selectedNodeId.value = null
}
function onNodeClick(payload: unknown, maybeNode?: Node) {
const node = (maybeNode || (payload as { node?: Node })?.node || payload) as Node | undefined
if (node?.id) selectedNodeId.value = node.id
}
function parseOutputHandle(handle?: string | null) { function parseOutputHandle(handle?: string | null) {
const match = handle?.match(/^(.*):out:(.*)$/) const match = handle?.match(/^(.*):out:(.*)$/)
return match ? { nodeId: match[1], outputName: match[2] } : null return match ? { nodeId: match[1], outputName: match[2] } : null
@@ -339,6 +450,94 @@ refreshFlow()
</dl> </dl>
</div> </div>
<div class="panel-card editor-card">
<div class="section-heading">
<h2>Add node</h2>
<button type="button" class="secondary" :disabled="!firstCatalogMatch" @click.stop.prevent="addFirstCatalogMatch">Add first</button>
</div>
<input v-model="nodeSearch" class="editor-input" type="search" placeholder="Filter node types" />
<div class="node-catalog">
<button
v-for="item in filteredNodeCatalog"
:key="item.name"
type="button"
class="secondary catalog-item"
:title="item.runnable ? 'Runnable in this UI' : 'Visual/edit only until optional runtime support is added'"
@click.stop.prevent="addNode(item.name)"
>
<span>{{ item.name }}</span>
<small>{{ item.runnable ? 'run' : 'view' }}</small>
</button>
</div>
</div>
<div v-if="selectedGraphNode && selectedFlowNode" class="panel-card editor-card">
<div class="section-heading">
<h2>{{ selectedGraphNode.node.name }}</h2>
<div class="mini-actions">
<button type="button" class="secondary" @click.stop.prevent="duplicateSelectedNode">Duplicate</button>
<button type="button" class="danger" @click.stop.prevent="deleteSelectedNode">Delete</button>
</div>
</div>
<label class="editor-field">
Node id
<input class="editor-input" :value="selectedGraphNode.node.id" @change="renameSelectedNodeId(($event.target as HTMLInputElement).value)" />
</label>
<div v-if="selectedFlowNode.data.inputs.length" class="io-editor">
<h3>Inputs</h3>
<div v-for="input in selectedFlowNode.data.inputs" :key="`in-${input.name}`" class="io-editor-row">
<div class="io-editor-row__head">
<strong>{{ input.name }}</strong>
<span class="io-type" :data-type="input.type">{{ input.type }}</span>
</div>
<label>
label
<input class="editor-input" :value="selectedGraphNode.node.options?.in?.[input.name]?.label || ''" @change="updateIoLabel('in', input.name, ($event.target as HTMLInputElement).value)" />
</label>
<label v-if="input.type !== 'Image'">
value
<input
v-if="input.type !== 'Boolean'"
class="editor-input"
:type="input.type === 'Number' ? 'number' : input.type === 'Color' ? 'color' : 'text'"
:value="displayIoValue(selectedGraphNode.node.options?.in?.[input.name]?.value)"
@change="updateInputValue(input.name, ($event.target as HTMLInputElement).value)"
/>
<select
v-else
class="editor-input"
:value="String(Boolean(selectedGraphNode.node.options?.in?.[input.name]?.value))"
@change="updateInputValue(input.name, ($event.target as HTMLSelectElement).value)"
>
<option value="false">false</option>
<option value="true">true</option>
</select>
</label>
<button v-if="input.output" type="button" class="secondary" @click="disconnectInput(input.name)">Disconnect</button>
</div>
</div>
<div v-if="selectedFlowNode.data.outputs.length" class="io-editor">
<h3>Outputs</h3>
<div v-for="output in selectedFlowNode.data.outputs" :key="`out-${output.name}`" class="io-editor-row">
<div class="io-editor-row__head">
<strong>{{ output.name }}</strong>
<span class="io-type" :data-type="output.type">{{ output.type }}</span>
</div>
<label>
label
<input class="editor-input" :value="selectedGraphNode.node.options?.out?.[output.name]?.label || ''" @change="updateIoLabel('out', output.name, ($event.target as HTMLInputElement).value)" />
</label>
</div>
</div>
</div>
<div v-else class="panel-card editor-card muted">
Select a node to edit labels, values, duplicate, or delete it.
</div>
<div class="panel-card"> <div class="panel-card">
<button type="button" class="run-button" @click="runGraph">Run graph</button> <button type="button" class="run-button" @click="runGraph">Run graph</button>
<p class="status-line">{{ runtimeStatus }}</p> <p class="status-line">{{ runtimeStatus }}</p>
@@ -428,6 +627,8 @@ refreshFlow()
:max-zoom="1.6" :max-zoom="1.6"
fit-view-on-init fit-view-on-init
@connect="onConnect" @connect="onConnect"
@node-click="onNodeClick"
@pane-click="clearSelection"
@node-drag-stop="onNodeDragStop" @node-drag-stop="onNodeDragStop"
@edge-double-click="onEdgeDoubleClick" @edge-double-click="onEdgeDoubleClick"
> >

View File

@@ -0,0 +1,95 @@
import { graphFxNodeDefinitions, type GraphFxIoType, type GraphFxNodeDefinition } from './nodeDefinitions'
import type { GraphFxIoValue, GraphFxSerializedGraph, GraphFxSerializedNode } from './graphfxToVueFlow'
export type NodeCatalogItem = {
name: string
runnable: boolean
definition: GraphFxNodeDefinition
}
const runtimeSupported = new Set([
'Resize', 'Compose', 'Fill', 'Flip', 'EmptySpace', 'Text', 'Rotate', 'Webcam', 'Api', 'ApiJson', 'Eval',
'QRCodeDetector', 'MarkerDetector', 'WaitForAll', 'QRCodeGenerator', 'Base64ToImg', 'ImgToBase64', 'Font',
'Disable', 'Numbers', 'NumberBinaryOperation', 'BrightnessContrast', 'HSV', 'Sepia', 'GreenScreen', 'Channels',
'GradientMap', 'ColorLookupTable', 'Vignette', 'Blur', 'SoftEdge', 'Edge', 'KernelPreset', 'Levels', 'LevelsAuto',
])
export const nodeCatalog: NodeCatalogItem[] = Object.entries(graphFxNodeDefinitions)
.map(([name, definition]) => ({ name, definition, runnable: runtimeSupported.has(name) }))
.sort((a, b) => a.name.localeCompare(b.name))
export function defaultValueForType(type: GraphFxIoType): unknown {
switch (type) {
case 'Number': return 0
case 'String': return ''
case 'Boolean': return false
case 'Color': return '#ffffff'
case 'Font': return null
case 'Image': return null
default: return null
}
}
function makeInput(type: GraphFxIoType): GraphFxIoValue {
return { label: null, value: defaultValueForType(type), output: null, type }
}
function makeOutput(type: GraphFxIoType): GraphFxIoValue {
return { label: null, type }
}
export function createSerializedNode(name: string, position = { x: 0, y: 0 }): GraphFxSerializedNode {
const definition = graphFxNodeDefinitions[name]
if (!definition) throw new Error(`Unknown node type "${name}".`)
return {
node: {
id: crypto.randomUUID(),
name,
options: {
in: Object.fromEntries(Object.entries(definition.in || {}).map(([io, type]) => [io, makeInput(type)])),
out: Object.fromEntries(Object.entries(definition.out || {}).map(([io, type]) => [io, makeOutput(type)])),
},
},
x: Math.round(position.x),
y: Math.round(position.y),
}
}
export function coerceIoValue(value: string, type: GraphFxIoType): unknown {
switch (type) {
case 'Number': {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
case 'Boolean': return value === 'true'
case 'Image': return null
case 'Font': return value ? value : null
case 'Color':
case 'String':
case 'Unknown':
default:
return value
}
}
export function displayIoValue(value: unknown): string {
if (value === null || value === undefined) return ''
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return JSON.stringify(value)
}
export function removeNodeAndConnections(graph: GraphFxSerializedGraph, nodeId: string): GraphFxSerializedGraph {
const removedOutputPrefix = `${nodeId}-out-`
return graph
.filter((item) => item.node.id !== nodeId)
.map((item) => {
for (const input of Object.values(item.node.options?.in || {})) {
if (input.output?.startsWith(removedOutputPrefix) || input.output?.startsWith(`${nodeId}-`)) {
input.output = null
}
}
return item
})
}

View File

@@ -254,6 +254,131 @@ code {
width: 100%; width: 100%;
} }
.editor-card {
gap: 0.75rem;
}
.editor-input {
min-width: 0;
width: 100%;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 10px;
padding: 0.5rem 0.62rem;
color: #f5f8ff;
background: #101827;
}
.editor-field,
.io-editor-row label {
display: grid;
gap: 0.28rem;
color: #9eb0cf;
font-size: 0.72rem;
font-weight: 850;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.node-catalog {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.42rem;
max-height: 190px;
overflow: auto;
padding-right: 0.2rem;
}
.catalog-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
padding: 0.48rem 0.55rem;
text-align: left;
font-size: 0.78rem;
}
.catalog-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.catalog-item small {
flex: 0 0 auto;
color: #7f93b7;
font-size: 0.62rem;
text-transform: uppercase;
}
.mini-actions {
display: flex;
gap: 0.38rem;
}
.mini-actions button,
.io-editor-row > button {
padding: 0.42rem 0.56rem;
font-size: 0.72rem;
}
button.danger {
background: linear-gradient(135deg, #ff6b6b, #ff9f68);
color: #170707;
box-shadow: 0 12px 28px rgb(255 107 107 / 16%);
}
.io-editor {
display: grid;
gap: 0.55rem;
}
.io-editor h3 {
margin: 0.2rem 0 0;
color: #d8e3fb;
font-size: 0.78rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.io-editor-row {
display: grid;
gap: 0.5rem;
padding: 0.58rem;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 14px;
background: rgb(5 9 19 / 58%);
}
.io-editor-row__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.io-editor-row__head strong {
color: #f8fbff;
font-size: 0.86rem;
}
.io-type {
border-radius: 999px;
padding: 0.12rem 0.42rem;
color: #06111c;
font-size: 0.64rem;
font-weight: 950;
background: #94a3b8;
}
.io-type[data-type="Image"] { background: #4dd7ff; }
.io-type[data-type="Number"] { background: #f6c85f; }
.io-type[data-type="String"] { background: #9ee66f; }
.io-type[data-type="Boolean"] { background: #c084fc; }
.io-type[data-type="Color"] { background: #ff78b7; }
.io-type[data-type="Font"] { background: #fb923c; }
.status-line, .status-line,
.muted { .muted {
margin: 0; margin: 0;