From 7927b34c5feb747e7b76703e0338f4a53c9fdf5d Mon Sep 17 00:00:00 2001 From: Ficik Date: Fri, 19 Jun 2026 01:14:17 +0200 Subject: [PATCH] Add full graph editor controls --- graphfx-ui/src/App.vue | 201 ++++++++++++++++++++++++++++ graphfx-ui/src/lib/editorHelpers.ts | 95 +++++++++++++ graphfx-ui/src/style.css | 125 +++++++++++++++++ 3 files changed, 421 insertions(+) create mode 100644 graphfx-ui/src/lib/editorHelpers.ts diff --git a/graphfx-ui/src/App.vue b/graphfx-ui/src/App.vue index f99f175..5177ebe 100644 --- a/graphfx-ui/src/App.vue +++ b/graphfx-ui/src/App.vue @@ -9,6 +9,7 @@ import '@vue-flow/controls/dist/style.css' import GraphFxNode from './components/GraphFxNode.vue' import { countSavedPositions, graphFxToVueFlow, parseGraphFxJson, type GraphFxFlowWarning, type GraphFxSerializedGraph } from './lib/graphfxToVueFlow' import { layoutGraph } from './lib/layout' +import { coerceIoValue, createSerializedNode, displayIoValue, nodeCatalog, removeNodeAndConnections } from './lib/editorHelpers' import { sampleGraph } from './lib/sampleGraph' import apiExampleJson from '../examples/api.json?raw' import { @@ -32,6 +33,8 @@ const selectedSaveSlot = ref(localStorage.getItem('GRAPHFX_UI_SELECTED_SAVE_SLOT const fileInput = ref(null) const sidebarCollapsed = ref(false) const activeTab = ref<'control' | 'data'>('control') +const selectedNodeId = ref(null) +const nodeSearch = ref('') const { fitView } = useVueFlow() const runtimeState = ref(null) @@ -49,6 +52,13 @@ const liveFrames = new Map() const labeledImageInputs = computed(() => runtimeState.value?.inputs || []) 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(() => ({ nodes: nodes.value.length, @@ -62,6 +72,7 @@ function refreshFlow() { const parsed = parseGraphFxJson(editorText.value) const model = graphFxToVueFlow(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, { direction: 'LR', useSavedPositions: useSavedPositions.value, @@ -79,6 +90,106 @@ function syncEditorFromGraph() { 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) { const match = handle?.match(/^(.*):out:(.*)$/) return match ? { nodeId: match[1], outputName: match[2] } : null @@ -339,6 +450,94 @@ refreshFlow() +
+
+

Add node

+ +
+ +
+ +
+
+ +
+
+

{{ selectedGraphNode.node.name }}

+
+ + +
+
+ + + +
+

Inputs

+
+
+ {{ input.name }} + {{ input.type }} +
+ + + +
+
+ +
+

Outputs

+
+
+ {{ output.name }} + {{ output.type }} +
+ +
+
+
+ +
+ Select a node to edit labels, values, duplicate, or delete it. +
+

{{ runtimeStatus }}

@@ -428,6 +627,8 @@ refreshFlow() :max-zoom="1.6" fit-view-on-init @connect="onConnect" + @node-click="onNodeClick" + @pane-click="clearSelection" @node-drag-stop="onNodeDragStop" @edge-double-click="onEdgeDoubleClick" > diff --git a/graphfx-ui/src/lib/editorHelpers.ts b/graphfx-ui/src/lib/editorHelpers.ts new file mode 100644 index 0000000..e0144c9 --- /dev/null +++ b/graphfx-ui/src/lib/editorHelpers.ts @@ -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 + }) +} diff --git a/graphfx-ui/src/style.css b/graphfx-ui/src/style.css index 6aaa6cf..0127652 100644 --- a/graphfx-ui/src/style.css +++ b/graphfx-ui/src/style.css @@ -254,6 +254,131 @@ code { 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, .muted { margin: 0;