Improve GraphFX UI editing and image previews

This commit is contained in:
2026-06-19 10:22:04 +02:00
parent 7927b34c5f
commit 8c58aaae2b
13 changed files with 1151 additions and 659 deletions

View File

@@ -1,8 +1,6 @@
node_modules
graphfx-ui/node_modules
graphfx-ui/dist
graphfx-ui/dist-ssr
**/node_modules
**/dist
.git
.gitignore
npm-debug.log*
.DS_Store
**/.vite

8
graphfx-ui/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
.git
.gitignore
Dockerfile
README.md
npm-debug.log*
.vite

View File

@@ -9,33 +9,37 @@ 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 { coerceIoValue, createSerializedNode, nodeCatalog, removeNodeAndConnections } from './lib/editorHelpers'
import { ioTypeFor } from './lib/nodeDefinitions'
import { sampleGraph } from './lib/sampleGraph'
import { imageTestGraph } from './lib/testGraph'
import { filesToStoredImages, loadHtmlImage, loadImageLibrary, saveImageLibrary, type StoredImage } from './lib/imageLibrary'
import apiExampleJson from '../examples/api.json?raw'
import {
createRuntimeGraph,
destroyRuntimeGraph,
setImageInput,
subscribeImageOutput,
type LabeledImageInput,
type LabeledImageOutput,
type RuntimeState,
} from './lib/graphRuntime'
const editorText = ref(JSON.stringify(sampleGraph, null, 2))
const IMAGE_SELECTION_KEY = 'GRAPHFX_UI_IMAGE_SELECTIONS'
const editorText = ref(JSON.stringify(imageTestGraph, null, 2))
const parseError = ref<string | null>(null)
const graph = ref<GraphFxSerializedGraph>(sampleGraph)
const graph = ref<GraphFxSerializedGraph>(imageTestGraph)
const nodes = ref<Node[]>([])
const edges = ref<Edge[]>([])
const warnings = ref<GraphFxFlowWarning[]>([])
const useSavedPositions = ref(true)
const selectedSaveSlot = ref(localStorage.getItem('GRAPHFX_UI_SELECTED_SAVE_SLOT') || 'My first graph')
const selectedSaveSlot = ref(localStorage.getItem('GRAPHFX_UI_SELECTED_SAVE_SLOT') || 'Image test graph')
const fileInput = ref<HTMLInputElement | null>(null)
const imageFileInput = ref<HTMLInputElement | null>(null)
const sidebarCollapsed = ref(false)
const activeTab = ref<'control' | 'data'>('control')
const selectedNodeId = ref<string | null>(null)
const nodeSearch = ref('')
const { fitView } = useVueFlow()
const addMenu = ref<{ x: number; y: number; flowX: number; flowY: number } | null>(null)
const { fitView, screenToFlowCoordinate } = useVueFlow()
const runtimeState = ref<RuntimeState | null>(null)
const runtimeStatus = ref('Graph not running')
@@ -43,22 +47,19 @@ const runtimeError = ref<string | null>(null)
const selectedOutputKey = ref('')
const outputPreviewUrl = ref<string | null>(null)
let unsubscribeOutput: (() => void) | null = null
let unsubscribeRuntimeOutputPreviews: Array<() => void> = []
const webcamVideo = ref<HTMLVideoElement | null>(null)
const webcamStream = ref<MediaStream | null>(null)
const webcamError = ref<string | null>(null)
const liveInputKeys = ref(new Set<string>())
const liveFrames = new Map<string, number>()
const runtimeImagePreviews = ref<Record<string, string>>({})
const imageLibrary = ref<StoredImage[]>(loadImageLibrary())
const selectedImageByInput = ref<Record<string, string>>(loadImageSelections())
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 imageOptions = computed(() => imageLibrary.value.map(({ id, name }) => ({ id, name })))
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,
@@ -67,20 +68,114 @@ const stats = computed(() => ({
warnings: warnings.value.length,
}))
function refreshFlow() {
const nodeActions = {
renameNodeId,
updateIoLabel,
updateInputValue,
disconnectInput,
deleteNode,
duplicateNode,
selectInputImage,
}
function loadImageSelections() {
try {
const raw = localStorage.getItem(IMAGE_SELECTION_KEY)
const parsed = raw ? JSON.parse(raw) : {}
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, string> : {}
} catch {
return {}
}
}
function saveImageSelections() {
localStorage.setItem(IMAGE_SELECTION_KEY, JSON.stringify(selectedImageByInput.value))
}
function selectedImagesForNode(nodeId: string) {
const result: Record<string, string> = {}
const prefix = `${nodeId}:`
for (const [key, imageId] of Object.entries(selectedImageByInput.value)) {
if (key.startsWith(prefix)) result[key.slice(prefix.length)] = imageId
}
return result
}
function selectedImageDataUrl(imageId: string | undefined) {
return imageLibrary.value.find((image) => image.id === imageId)?.dataUrl || ''
}
function previewKey(direction: 'in' | 'out', nodeId: string, ioName: string) {
return `${direction}:${nodeId}:${ioName}`
}
function outputPreviewForSerializedId(outputId?: string | null) {
if (!outputId) return ''
for (const entry of graph.value) {
for (const outputName of Object.keys(entry.node.options?.out || {})) {
if (outputId === `${entry.node.id}-out-${outputName}` || outputId === `${entry.node.id}-${outputName}`) {
return runtimeImagePreviews.value[previewKey('out', entry.node.id, outputName)] || ''
}
}
}
return ''
}
function imagePreviewsForNode(node: Node) {
const result: Record<string, string> = {}
for (const input of node.data?.inputs || []) {
const selectedUrl = selectedImageDataUrl(selectedImageByInput.value[`${node.id}:${input.name}`])
const connectedUrl = outputPreviewForSerializedId(input.output)
const runtimeUrl = runtimeImagePreviews.value[previewKey('in', node.id, input.name)]
const url = connectedUrl || selectedUrl || runtimeUrl || input.previewUrl || ''
if (url) result[`in:${input.name}`] = url
}
for (const output of node.data?.outputs || []) {
const url = runtimeImagePreviews.value[previewKey('out', node.id, output.name)] || output.previewUrl || ''
if (url) result[`out:${output.name}`] = url
}
return result
}
function decorateNodes(nodeList: Node[]) {
return nodeList.map((node) => ({
...node,
data: {
...node.data,
actions: nodeActions,
imageOptions: imageOptions.value,
selectedImages: selectedImagesForNode(node.id),
imagePreviews: imagePreviewsForNode(node),
runtimeReady: Boolean(runtimeState.value?.graph),
},
}))
}
function refreshFlow(options: { fit?: boolean; layout?: boolean } = {}) {
try {
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,
})
const flowNodes = options.layout
? layoutGraph(model.nodes, model.edges, { direction: 'LR', useSavedPositions: false })
: model.nodes
if (options.layout) {
for (const flowNode of flowNodes) {
const entry = graph.value.find((item) => item.node.id === flowNode.id)
if (entry) {
entry.x = Math.round(flowNode.position.x)
entry.y = Math.round(flowNode.position.y)
}
}
syncEditorFromGraph()
}
nodes.value = decorateNodes(flowNodes)
edges.value = model.edges
warnings.value = model.warnings
parseError.value = null
nextTick(() => fitView({ padding: 0.18, duration: 250 }))
if (options.fit) nextTick(() => fitView({ padding: 0.2, duration: 250 }))
} catch (error) {
parseError.value = error instanceof Error ? error.message : String(error)
}
@@ -90,106 +185,116 @@ function syncEditorFromGraph() {
editorText.value = JSON.stringify(graph.value, null, 2)
}
function refreshFromGraph(options: { fit?: boolean } = {}) {
function refreshFromGraph(options: { fit?: boolean; layout?: boolean } = {}) {
syncEditorFromGraph()
refreshFlow()
if (options.fit) nextTick(() => fitView({ padding: 0.18, duration: 250 }))
refreshFlow(options)
if (options.fit) nextTick(() => fitView({ padding: 0.2, duration: 250 }))
}
function applyAutoLayout() {
refreshFromGraph({ layout: true, fit: true })
}
function fitGraph() {
nextTick(() => fitView({ padding: 0.22, duration: 260 }))
}
function findGraphNode(nodeId: string) {
return graph.value.find((item) => item.node.id === nodeId) || null
}
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) }
return { x: maxX + 420, y: Math.max(40, maxY + 40) }
}
function addNode(name: string) {
function addNode(name: string, position = defaultNewNodePosition()) {
try {
const node = createSerializedNode(name, defaultNewNodePosition())
const node = createSerializedNode(name, position)
graph.value = [...graph.value, node]
selectedNodeId.value = node.node.id
refreshFromGraph({ fit: true })
activeTab.value = 'control'
closeAddMenu()
} 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
function deleteNode(nodeId: string) {
graph.value = removeNodeAndConnections(graph.value, nodeId)
for (const key of Object.keys(selectedImageByInput.value)) {
if (key.startsWith(`${nodeId}:`)) delete selectedImageByInput.value[key]
}
saveImageSelections()
refreshFromGraph()
}
function duplicateSelectedNode() {
const source = selectedGraphNode.value
function duplicateNode(nodeId: string) {
const source = findGraphNode(nodeId)
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
clone.x = (typeof source.x === 'number' ? source.x : 0) + 80
clone.y = (typeof source.y === 'number' ? source.y : 0) + 80
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
function renameNodeId(nodeId: string, nextId: string) {
const item = findGraphNode(nodeId)
const clean = nextId.trim()
if (!item || !oldId || !clean || clean === oldId) return
if (!item || !clean || clean === nodeId) 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-`
const oldCanonical = `${nodeId}-out-`
const newCanonical = `${clean}-out-`
const oldCompat = `${nodeId}-`
const newCompat = `${clean}-`
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-`)
if (input.output?.startsWith(oldCanonical)) input.output = input.output.replace(oldCanonical, newCanonical)
else if (input.output?.startsWith(oldCompat)) input.output = input.output.replace(oldCompat, newCompat)
}
}
selectedNodeId.value = clean
const nextSelections: Record<string, string> = {}
for (const [key, value] of Object.entries(selectedImageByInput.value)) {
nextSelections[key.startsWith(`${nodeId}:`) ? `${clean}:${key.slice(nodeId.length + 1)}` : key] = value
}
selectedImageByInput.value = nextSelections
saveImageSelections()
refreshFromGraph()
}
function updateIoLabel(direction: 'in' | 'out', ioName: string, label: string) {
const io = selectedGraphNode.value?.node.options?.[direction]?.[ioName]
function updateIoLabel(nodeId: string, direction: 'in' | 'out', ioName: string, label: string) {
const io = findGraphNode(nodeId)?.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)
function updateInputValue(nodeId: string, ioName: string, rawValue: string | boolean) {
const item = findGraphNode(nodeId)
const input = item?.node.options?.in?.[ioName]
if (!item || !input) return
const type = ioTypeFor(item.node.name, 'in', ioName, input.type || input.definition?.type)
input.value = coerceIoValue(String(rawValue), type)
input.output = null
refreshFromGraph()
}
function disconnectInput(ioName: string) {
const input = selectedGraphNode.value?.node.options?.in?.[ioName]
function disconnectInput(nodeId: string, ioName: string) {
const input = findGraphNode(nodeId)?.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
@@ -234,12 +339,39 @@ function onEdgeDoubleClick(payload: unknown, maybeEdge?: Edge) {
updateInputConnection(target.nodeId, target.inputName, null)
}
function openAddMenu(event: MouseEvent) {
const target = event.target as HTMLElement | null
if (target?.closest('.graphfx-node') || target?.closest('.context-menu')) return
const point = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
const menuWidth = 350
const menuHeight = Math.min(540, window.innerHeight - 24)
const x = Math.max(12, Math.min(event.clientX, window.innerWidth - menuWidth - 12))
const y = Math.max(12, Math.min(event.clientY, window.innerHeight - menuHeight - 12))
addMenu.value = { x, y, flowX: point.x, flowY: point.y }
nodeSearch.value = ''
}
function closeAddMenu() {
addMenu.value = null
}
function addNodeFromMenu(name: string) {
const pos = addMenu.value ? { x: addMenu.value.flowX, y: addMenu.value.flowY } : defaultNewNodePosition()
addNode(name, pos)
}
function loadSample() {
editorText.value = JSON.stringify(sampleGraph, null, 2)
selectedSaveSlot.value = 'sample'
refreshFlow()
}
function loadImageTestGraph() {
editorText.value = JSON.stringify(imageTestGraph, null, 2)
selectedSaveSlot.value = 'Image test graph'
refreshFlow()
}
function loadApiExample() {
editorText.value = JSON.stringify(parseGraphFxJson(apiExampleJson), null, 2)
selectedSaveSlot.value = 'api'
@@ -295,102 +427,10 @@ function loadJsonFromEditor() {
if (!parseError.value) activeTab.value = 'control'
}
function stopAllLiveInputs() {
for (const frame of liveFrames.values()) cancelAnimationFrame(frame)
liveFrames.clear()
liveInputKeys.value = new Set()
}
function stopLiveInput(key: string) {
const frame = liveFrames.get(key)
if (frame) cancelAnimationFrame(frame)
liveFrames.delete(key)
const next = new Set(liveInputKeys.value)
next.delete(key)
liveInputKeys.value = next
}
async function runGraph() {
refreshFlow()
if (parseError.value) return
stopAllLiveInputs()
unsubscribeOutput?.()
unsubscribeOutput = null
outputPreviewUrl.value = null
destroyRuntimeGraph(runtimeState.value)
runtimeState.value = null
runtimeError.value = null
runtimeStatus.value = 'Starting graph…'
try {
runtimeState.value = await createRuntimeGraph(graph.value)
runtimeStatus.value = `Running: ${runtimeState.value.inputs.length} labeled image inputs, ${runtimeState.value.outputs.length} labeled image outputs`
selectedOutputKey.value = runtimeState.value.outputs[0]?.key || ''
subscribeSelectedOutput()
} catch (error) {
runtimeError.value = error instanceof Error ? error.message : String(error)
runtimeStatus.value = 'Graph failed to start'
}
}
async function openWebcam() {
if (webcamStream.value) return
webcamError.value = null
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
webcamStream.value = stream
if (webcamVideo.value) {
webcamVideo.value.srcObject = stream
await webcamVideo.value.play()
}
} catch (error) {
webcamError.value = error instanceof Error ? error.message : String(error)
}
}
function closeWebcam() {
stopAllLiveInputs()
webcamStream.value?.getTracks().forEach((track) => track.stop())
webcamStream.value = null
if (webcamVideo.value) webcamVideo.value.srcObject = null
}
function outputByKey(key: string): LabeledImageOutput | undefined {
return labeledImageOutputs.value.find((output) => output.key === key)
}
function pushWebcamFrame(input: LabeledImageInput) {
if (!runtimeState.value?.graph || !webcamVideo.value) return
if (!webcamVideo.value.videoWidth || !webcamVideo.value.videoHeight) return
setImageInput(runtimeState.value.graph, input, webcamVideo.value)
}
async function snapshotInput(input: LabeledImageInput) {
await openWebcam()
stopLiveInput(input.key)
pushWebcamFrame(input)
}
async function toggleLiveInput(input: LabeledImageInput) {
if (liveInputKeys.value.has(input.key)) {
stopLiveInput(input.key)
return
}
await openWebcam()
const next = new Set(liveInputKeys.value)
next.add(input.key)
liveInputKeys.value = next
const tick = () => {
if (!liveInputKeys.value.has(input.key)) return
pushWebcamFrame(input)
liveFrames.set(input.key, requestAnimationFrame(tick))
}
tick()
}
function subscribeSelectedOutput() {
unsubscribeOutput?.()
unsubscribeOutput = null
@@ -403,13 +443,146 @@ function subscribeSelectedOutput() {
})
}
watch(useSavedPositions, refreshFlow)
function unsubscribeRuntimePreviewOutputs() {
for (const unsubscribe of unsubscribeRuntimeOutputPreviews) unsubscribe()
unsubscribeRuntimeOutputPreviews = []
}
function subscribeRuntimePreviewOutputs() {
unsubscribeRuntimePreviewOutputs()
if (!runtimeState.value?.graph) return
unsubscribeRuntimeOutputPreviews = runtimeState.value.outputs.map((output) => subscribeImageOutput(runtimeState.value!.graph!, output, (url) => {
const key = previewKey('out', output.nodeId, output.ioName)
const next = { ...runtimeImagePreviews.value }
if (url) next[key] = url
else delete next[key]
runtimeImagePreviews.value = next
refreshFlow()
}))
}
async function runGraph() {
refreshFlow()
if (parseError.value) return
unsubscribeOutput?.()
unsubscribeOutput = null
unsubscribeRuntimePreviewOutputs()
outputPreviewUrl.value = null
runtimeImagePreviews.value = {}
destroyRuntimeGraph(runtimeState.value)
runtimeState.value = null
runtimeError.value = null
runtimeStatus.value = 'Starting graph…'
try {
runtimeState.value = await createRuntimeGraph(graph.value)
runtimeStatus.value = `Preview ready: ${runtimeState.value.inputs.length} image inputs, ${runtimeState.value.outputs.length} image outputs`
selectedOutputKey.value = runtimeState.value.outputs[0]?.key || ''
subscribeRuntimePreviewOutputs()
refreshFlow()
subscribeSelectedOutput()
await applySelectedImagesToRuntime()
} catch (error) {
runtimeError.value = error instanceof Error ? error.message : String(error)
runtimeStatus.value = 'Graph failed to start'
refreshFlow()
}
}
async function applySelectedImagesToRuntime() {
if (!runtimeState.value?.graph) return
const selected = Object.entries(selectedImageByInput.value)
let applied = 0
for (const [inputKey, imageId] of selected) {
const input = runtimeState.value.inputs.find((item) => `${item.nodeId}:${item.ioName}` === inputKey)
const image = imageLibrary.value.find((item) => item.id === imageId)
if (!input || !image) continue
const htmlImage = await loadHtmlImage(image.dataUrl)
setImageInput(runtimeState.value.graph, input, htmlImage)
runtimeImagePreviews.value = {
...runtimeImagePreviews.value,
[previewKey('in', input.nodeId, input.ioName)]: image.dataUrl,
}
applied += 1
}
if (applied) {
runtimeStatus.value = `Preview updated: applied ${applied} stored image input${applied === 1 ? '' : 's'}`
refreshFlow()
}
}
async function selectInputImage(nodeId: string, ioName: string, imageId: string) {
selectedImageByInput.value = { ...selectedImageByInput.value, [`${nodeId}:${ioName}`]: imageId }
saveImageSelections()
refreshFlow()
if (!runtimeState.value?.graph) await runGraph()
else await applySelectedImagesToRuntime()
}
async function importImages(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files?.length) return
const imported = await filesToStoredImages(input.files)
imageLibrary.value = [...imported, ...imageLibrary.value]
saveImageLibrary(imageLibrary.value)
input.value = ''
refreshFlow()
}
function removeImage(imageId: string) {
imageLibrary.value = imageLibrary.value.filter((image) => image.id !== imageId)
for (const [key, selectedId] of Object.entries(selectedImageByInput.value)) {
if (selectedId === imageId) delete selectedImageByInput.value[key]
}
saveImageLibrary(imageLibrary.value)
saveImageSelections()
refreshFlow()
}
function makeTestImage(name: string, background: string, accent: string) {
const canvas = document.createElement('canvas')
canvas.width = 900
canvas.height = 600
const ctx = canvas.getContext('2d')!
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
gradient.addColorStop(0, background)
gradient.addColorStop(1, '#060914')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = accent
ctx.beginPath()
ctx.arc(680, 180, 130, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = 'rgba(255,255,255,.65)'
ctx.lineWidth = 18
ctx.strokeRect(70, 70, 760, 460)
ctx.fillStyle = '#ffffff'
ctx.font = '900 72px system-ui, sans-serif'
ctx.fillText(name, 115, 300)
ctx.font = '700 34px system-ui, sans-serif'
ctx.fillText('GraphFX local image input', 118, 355)
return canvas.toDataURL('image/png')
}
function addGeneratedTestImages() {
const existingNames = new Set(imageLibrary.value.map((image) => image.name))
const generated: StoredImage[] = [
{ id: crypto.randomUUID(), name: 'test-neon-blue.png', dataUrl: makeTestImage('BLUE', '#1246ff', '#37d5ff'), createdAt: Date.now() },
{ id: crypto.randomUUID(), name: 'test-warm-magenta.png', dataUrl: makeTestImage('MAGENTA', '#af1b6d', '#ff78b7'), createdAt: Date.now() },
{ id: crypto.randomUUID(), name: 'test-green-room.png', dataUrl: makeTestImage('GREEN', '#067a58', '#33e1ba'), createdAt: Date.now() },
].filter((image) => !existingNames.has(image.name))
if (!generated.length) return
imageLibrary.value = [...generated, ...imageLibrary.value]
saveImageLibrary(imageLibrary.value)
refreshFlow()
}
watch(selectedOutputKey, subscribeSelectedOutput)
onBeforeUnmount(() => {
stopAllLiveInputs()
closeWebcam()
unsubscribeOutput?.()
unsubscribeRuntimePreviewOutputs()
destroyRuntimeGraph(runtimeState.value)
})
@@ -417,8 +590,8 @@ refreshFlow()
</script>
<template>
<main class="app-shell" :class="{ 'app-shell--collapsed': sidebarCollapsed }">
<aside class="sidebar" :aria-hidden="sidebarCollapsed">
<main class="app-shell" :class="{ 'app-shell--collapsed': sidebarCollapsed }" @click="closeAddMenu">
<aside class="sidebar" :aria-hidden="sidebarCollapsed" @click.stop>
<div class="savebar savebar--primary">
<input v-model="selectedSaveSlot" type="text" aria-label="Graph name" placeholder="Graph name" />
<button type="button" class="secondary" @click="loadGraph">Load</button>
@@ -431,14 +604,43 @@ refreshFlow()
</div>
<section v-if="activeTab === 'control'" class="tab-panel">
<div class="panel-card quickstart-card">
<p class="hint"><strong>Canvas is the editor.</strong> Right-click empty space to add nodes. Edit ids, labels, values, image inputs, duplicate/delete directly on each node. Double-click an edge to disconnect.</p>
</div>
<div class="panel-card">
<div class="control-row">
<span>Saved layout</span>
<button type="button" class="toggle" :class="{ active: useSavedPositions }" @click="useSavedPositions = !useSavedPositions">
{{ useSavedPositions ? 'On' : 'Off' }}
</button>
<div class="section-heading">
<h2>Preview</h2>
<button type="button" class="secondary compact" @click="runGraph">Refresh preview</button>
</div>
<p class="status-line">{{ runtimeStatus }}</p>
<p v-if="runtimeError" class="error">{{ runtimeError }}</p>
<div v-if="runtimeState" class="runtime-summary">
<span>{{ labeledImageInputs.length }} image inputs</span>
<span>{{ labeledImageOutputs.length }} image outputs</span>
</div>
<select v-model="selectedOutputKey" :disabled="!labeledImageOutputs.length">
<option value="">Select labeled image output</option>
<option v-for="output in labeledImageOutputs" :key="output.key" :value="output.key">
{{ output.label }} {{ output.nodeName }}.{{ output.ioName }}
</option>
</select>
<div class="output-preview output-preview--compact">
<img v-if="outputPreviewUrl" :src="outputPreviewUrl" alt="Selected GraphFX output preview" />
<span v-else>No image yet</span>
</div>
</div>
<div class="panel-card">
<div class="section-heading">
<h2>Layout</h2>
<div class="mini-actions">
<button type="button" class="secondary" @click="applyAutoLayout">Auto layout</button>
<button type="button" class="secondary" @click="fitGraph">Fit view</button>
</div>
</div>
<div class="toolbar toolbar--examples">
<button type="button" class="secondary" @click="loadImageTestGraph">Image test</button>
<button type="button" class="secondary" @click="loadSample">Sample</button>
<button type="button" class="secondary" @click="loadApiExample">api.json</button>
</div>
@@ -450,138 +652,23 @@ refreshFlow()
</dl>
</div>
<div class="panel-card editor-card">
<div class="panel-card image-library-card">
<div class="section-heading">
<h2>Add node</h2>
<button type="button" class="secondary" :disabled="!firstCatalogMatch" @click.stop.prevent="addFirstCatalogMatch">Add first</button>
<h2>Browser image library</h2>
<button type="button" class="secondary" @click="imageFileInput?.click()">Upload</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">
<button type="button" class="run-button" @click="runGraph">Run graph</button>
<p class="status-line">{{ runtimeStatus }}</p>
<p v-if="runtimeError" class="error">{{ runtimeError }}</p>
</div>
<div class="panel-card">
<div class="section-heading">
<h2>Webcam input</h2>
<button type="button" class="secondary" @click="webcamStream ? closeWebcam() : openWebcam()">
{{ webcamStream ? 'Close webcam' : 'Open webcam' }}
</button>
</div>
<video ref="webcamVideo" class="webcam-preview" muted playsinline />
<p v-if="webcamError" class="error">{{ webcamError }}</p>
<p v-if="!runtimeState" class="muted">Run the graph to discover labeled image inputs.</p>
<div v-else-if="!labeledImageInputs.length" class="muted">No labeled image inputs found.</div>
<div v-else class="io-actions">
<div v-for="input in labeledImageInputs" :key="input.key" class="io-action-row">
<input ref="imageFileInput" class="visually-hidden" type="file" multiple accept="image/*" @change="importImages" />
<button type="button" class="secondary" @click="addGeneratedTestImages">Add 3 test images</button>
<p class="hint">Images are stored in this browser. Pick them from unconnected Image inputs on the node itself.</p>
<div v-if="!imageLibrary.length" class="muted">No stored images yet.</div>
<div v-else class="image-grid">
<article v-for="image in imageLibrary" :key="image.id" class="image-tile">
<img :src="image.dataUrl" :alt="image.name" />
<div>
<strong>{{ input.label }}</strong>
<small>{{ input.nodeName }}.{{ input.ioName }}</small>
<strong>{{ image.name }}</strong>
<button type="button" class="ghost danger" @click="removeImage(image.id)">remove</button>
</div>
<button type="button" class="secondary" :class="{ active: liveInputKeys.has(input.key) }" @click="toggleLiveInput(input)">
{{ liveInputKeys.has(input.key) ? 'Stop live' : 'Live' }}
</button>
<button type="button" class="secondary" @click="snapshotInput(input)">Snapshot</button>
</div>
</div>
</div>
<div class="panel-card">
<div class="section-heading">
<h2>Output</h2>
</div>
<select v-model="selectedOutputKey" :disabled="!labeledImageOutputs.length">
<option value="">Select labeled image output</option>
<option v-for="output in labeledImageOutputs" :key="output.key" :value="output.key">
{{ output.label }} {{ output.nodeName }}.{{ output.ioName }}
</option>
</select>
<div class="output-preview">
<img v-if="outputPreviewUrl" :src="outputPreviewUrl" alt="Selected GraphFX output preview" />
<span v-else>No image yet</span>
</article>
</div>
</div>
</section>
@@ -605,17 +692,17 @@ refreshFlow()
</div>
<label class="json-editor">
JSON
Serialized graph JSON
<textarea v-model="editorText" spellcheck="false" />
</label>
</section>
</aside>
<button type="button" class="sidebar-toggle" :aria-expanded="!sidebarCollapsed" @click="sidebarCollapsed = !sidebarCollapsed">
<button type="button" class="sidebar-toggle" :aria-expanded="!sidebarCollapsed" @click.stop="sidebarCollapsed = !sidebarCollapsed">
{{ sidebarCollapsed ? '' : '' }}
</button>
<section class="flow-panel" aria-label="GraphFX graph visualization">
<section class="flow-panel" aria-label="GraphFX graph visualization" @contextmenu.prevent="openAddMenu">
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
@@ -623,12 +710,9 @@ refreshFlow()
:nodes-draggable="true"
:nodes-connectable="true"
:elements-selectable="true"
:min-zoom="0.15"
:min-zoom="0.25"
:max-zoom="1.6"
fit-view-on-init
@connect="onConnect"
@node-click="onNodeClick"
@pane-click="clearSelection"
@node-drag-stop="onNodeDragStop"
@edge-double-click="onEdgeDoubleClick"
>
@@ -639,5 +723,32 @@ refreshFlow()
<Controls />
</VueFlow>
</section>
<div
v-if="addMenu"
class="context-menu"
:style="{ left: `${addMenu.x}px`, top: `${addMenu.y}px` }"
@click.stop
@contextmenu.prevent
>
<div class="context-menu__head">
<strong>Add node</strong>
<button type="button" class="ghost" @click="closeAddMenu">×</button>
</div>
<input v-model="nodeSearch" class="editor-input" type="search" autofocus placeholder="Filter node types" @keydown.esc="closeAddMenu" />
<div class="context-menu__list">
<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="addNodeFromMenu(item.name)"
>
<span>{{ item.name }}</span>
<small>{{ item.runnable ? 'run' : 'view' }}</small>
</button>
</div>
</div>
</main>
</template>

View File

@@ -2,6 +2,7 @@
import { Handle, Position } from '@vue-flow/core'
import type { GraphFxNodeData, GraphFxPort } from '../lib/graphfxToVueFlow'
import { getInputHandleId, getOutputHandleId } from '../lib/graphfxToVueFlow'
import { displayIoValue } from '../lib/editorHelpers'
const props = defineProps<{
data: GraphFxNodeData
@@ -11,25 +12,94 @@ function typeClass(port: GraphFxPort) {
return `io-type-${port.type.toLowerCase()}`
}
function valuePreview(port: GraphFxPort) {
if (port.output) return ''
if (port.value === null || port.value === undefined) return ''
if (typeof port.value === 'string') return port.value.length > 24 ? `${port.value.slice(0, 24)}` : port.value
if (typeof port.value === 'number' || typeof port.value === 'boolean') return String(port.value)
if (typeof port.value === 'object') return 'object'
return String(port.value)
}
function hasCustomLabel(port: GraphFxPort) {
return port.label && port.label !== port.name
}
function labelValue(port: GraphFxPort) {
return hasCustomLabel(port) ? port.label : ''
}
function previewUrl(direction: 'in' | 'out', port: GraphFxPort) {
return props.data.imagePreviews?.[`${direction}:${port.name}`] || port.previewUrl || ''
}
function hasSelectOptions(port: GraphFxPort) {
return Boolean(port.options?.length)
}
function updateNodeId(event: Event) {
const target = event.target as HTMLInputElement
props.data.actions?.renameNodeId(props.data.id, target.value)
}
function updateLabel(direction: 'in' | 'out', port: GraphFxPort, event: Event) {
const target = event.target as HTMLInputElement
props.data.actions?.updateIoLabel(props.data.id, direction, port.name, target.value)
}
function updateInput(port: GraphFxPort, event: Event) {
const target = event.target as HTMLInputElement | HTMLSelectElement
const value = port.type === 'Boolean' ? (target as HTMLInputElement).checked : target.value
props.data.actions?.updateInputValue(props.data.id, port.name, value)
}
function selectImage(port: GraphFxPort, event: Event) {
const target = event.target as HTMLSelectElement
if (!target.value) return
props.data.actions?.selectInputImage(props.data.id, port.name, target.value)
}
function selectedImage(port: GraphFxPort) {
return props.data.selectedImages?.[port.name] || ''
}
function deleteNode() {
props.data.actions?.deleteNode(props.data.id)
}
function duplicateNode() {
props.data.actions?.duplicateNode(props.data.id)
}
function disconnectInput(port: GraphFxPort) {
props.data.actions?.disconnectInput(props.data.id, port.name)
}
function inputType(port: GraphFxPort) {
if (port.type === 'Number') return 'number'
if (port.type === 'Color') return 'color'
return 'text'
}
function connectedText(port: GraphFxPort) {
return port.connectedDisplay || port.output || 'connected'
}
</script>
<template>
<article class="graphfx-node">
<header class="graphfx-node__header">
<h3>{{ data.name }}</h3>
<small>{{ data.id }}</small>
<div>
<h3>{{ data.name }}</h3>
<input
class="graphfx-node__id nodrag"
:value="data.id"
aria-label="Node id"
title="Node id"
@change="updateNodeId"
@pointerdown.stop
@keydown.stop
/>
</div>
<details class="graphfx-node__menu nodrag" @pointerdown.stop @click.stop>
<summary title="Node actions"></summary>
<div class="graphfx-node__menu-popover">
<button type="button" class="secondary" @click="duplicateNode">Duplicate</button>
<button type="button" class="danger" @click="deleteNode">Delete</button>
</div>
</details>
</header>
<section class="graphfx-node__ports" :class="{ 'graphfx-node__ports--empty': !data.inputs.length && !data.outputs.length }">
@@ -48,9 +118,75 @@ function hasCustomLabel(port: GraphFxPort) {
:position="Position.Left"
class="graphfx-node__handle graphfx-node__handle--input"
/>
<span class="graphfx-node__port-name">{{ port.name }}</span>
<span v-if="hasCustomLabel(port)" class="graphfx-node__port-label">{{ port.label }}</span>
<span v-if="valuePreview(port)" class="graphfx-node__port-value">{{ valuePreview(port) }}</span>
<div class="graphfx-node__port-head">
<span class="graphfx-node__port-name">{{ port.name }}</span>
<span v-if="hasCustomLabel(port)" class="graphfx-node__label-chip">{{ port.label }}</span>
<span class="graphfx-node__port-type">{{ port.type }}</span>
</div>
<img v-if="port.type === 'Image' && previewUrl('in', port)" class="graphfx-node__image-preview" :src="previewUrl('in', port)" :alt="`${port.name} preview`" />
<div v-if="port.output" class="graphfx-node__connected-value nodrag" @pointerdown.stop>
<span :title="port.output"> {{ connectedText(port) }}</span>
<button type="button" class="ghost danger" title="Disconnect input" @click="disconnectInput(port)">×</button>
</div>
<template v-else>
<select
v-if="port.type === 'Image'"
class="graphfx-node__value-input nodrag"
:value="selectedImage(port)"
:disabled="!data.imageOptions?.length"
title="Use uploaded image as this input"
@change="selectImage(port, $event)"
@pointerdown.stop
@keydown.stop
>
<option value="">{{ data.imageOptions?.length ? 'choose uploaded image' : 'upload images first' }}</option>
<option v-for="image in data.imageOptions" :key="image.id" :value="image.id">{{ image.name }}</option>
</select>
<select
v-else-if="hasSelectOptions(port)"
class="graphfx-node__value-input nodrag"
:value="displayIoValue(port.value)"
title="Input value"
@change="updateInput(port, $event)"
@pointerdown.stop
@keydown.stop
>
<option v-for="option in port.options" :key="option" :value="option">{{ option }}</option>
</select>
<label v-else-if="port.type === 'Boolean'" class="graphfx-node__checkbox nodrag" @pointerdown.stop>
<input :checked="Boolean(port.value)" type="checkbox" @change="updateInput(port, $event)" />
{{ Boolean(port.value) ? 'true' : 'false' }}
</label>
<input
v-else
class="graphfx-node__value-input nodrag"
:type="inputType(port)"
:value="displayIoValue(port.value)"
title="Input value"
@change="updateInput(port, $event)"
@pointerdown.stop
@keydown.stop
/>
</template>
<details class="graphfx-node__label-editor nodrag" @pointerdown.stop @click.stop>
<summary title="Edit input label">label</summary>
<input
class="graphfx-node__label-input"
:value="labelValue(port)"
placeholder="optional label"
title="Input label"
@change="updateLabel('in', port, $event)"
@keydown.stop
/>
</details>
</div>
</div>
@@ -63,8 +199,26 @@ function hasCustomLabel(port: GraphFxPort) {
:class="[typeClass(port), { 'graphfx-node__port--labeled': hasCustomLabel(port) }]"
:title="`${port.name} · ${port.type}`"
>
<span class="graphfx-node__port-name">{{ port.name }}</span>
<span v-if="hasCustomLabel(port)" class="graphfx-node__port-label">{{ port.label }}</span>
<div class="graphfx-node__port-head graphfx-node__port-head--right">
<span class="graphfx-node__port-type">{{ port.type }}</span>
<span v-if="hasCustomLabel(port)" class="graphfx-node__label-chip">{{ port.label }}</span>
<span class="graphfx-node__port-name">{{ port.name }}</span>
</div>
<img v-if="port.type === 'Image' && previewUrl('out', port)" class="graphfx-node__image-preview" :src="previewUrl('out', port)" :alt="`${port.name} preview`" />
<details class="graphfx-node__label-editor nodrag" @pointerdown.stop @click.stop>
<summary title="Edit output label">label</summary>
<input
class="graphfx-node__label-input"
:value="labelValue(port)"
placeholder="optional label"
title="Output label"
@change="updateLabel('out', port, $event)"
@keydown.stop
/>
</details>
<Handle
type="source"
:id="getOutputHandleId(data.id, port.name)"

View File

@@ -1,4 +1,4 @@
import { graphFxNodeDefinitions, type GraphFxIoType, type GraphFxNodeDefinition } from './nodeDefinitions'
import { graphFxNodeDefinitions, type GraphFxIoDefinition, type GraphFxIoType, type GraphFxNodeDefinition } from './nodeDefinitions'
import type { GraphFxIoValue, GraphFxSerializedGraph, GraphFxSerializedNode } from './graphfxToVueFlow'
export type NodeCatalogItem = {
@@ -30,12 +30,21 @@ export function defaultValueForType(type: GraphFxIoType): unknown {
}
}
function makeInput(type: GraphFxIoType): GraphFxIoValue {
return { label: null, value: defaultValueForType(type), output: null, type }
function definitionType(definition: GraphFxIoDefinition): GraphFxIoType {
return typeof definition === 'string' ? definition : definition.type
}
function makeOutput(type: GraphFxIoType): GraphFxIoValue {
return { label: null, type }
function defaultValueForDefinition(definition: GraphFxIoDefinition): unknown {
if (typeof definition === 'object' && definition.options?.length) return definition.options[0]
return defaultValueForType(definitionType(definition))
}
function makeInput(definition: GraphFxIoDefinition): GraphFxIoValue {
return { label: null, value: defaultValueForDefinition(definition), output: null, type: definitionType(definition) }
}
function makeOutput(definition: GraphFxIoDefinition): GraphFxIoValue {
return { label: null, type: definitionType(definition) }
}
export function createSerializedNode(name: string, position = { x: 0, y: 0 }): GraphFxSerializedNode {
@@ -47,8 +56,8 @@ export function createSerializedNode(name: string, position = { x: 0, y: 0 }): G
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)])),
in: Object.fromEntries((Object.entries(definition.in || {}) as [string, GraphFxIoDefinition][]).map(([io, definition]) => [io, makeInput(definition)])),
out: Object.fromEntries((Object.entries(definition.out || {}) as [string, GraphFxIoDefinition][]).map(([io, definition]) => [io, makeOutput(definition)])),
},
},
x: Math.round(position.x),

View File

@@ -147,13 +147,13 @@ export async function createRuntimeGraph(serializedGraph: GraphFxSerializedGraph
for (const { node } of graph.nodes) {
for (const ioName of Object.keys(node.in.variables || {})) {
const input = node.in[ioName] as RuntimeInput | undefined
if (input?.type === 'Image' && input.label) {
if (input?.type === 'Image') {
inputs.push({
key: ioKey(node.id, ioName),
nodeId: node.id,
nodeName: node.name,
ioName,
label: input.label,
label: input.label || ioName,
connectedOutput: input.output ? `${input.output.name}` : null,
})
}
@@ -161,13 +161,13 @@ export async function createRuntimeGraph(serializedGraph: GraphFxSerializedGraph
for (const ioName of Object.keys(node.out.variables || {})) {
const output = node.out[ioName] as RuntimeOutput | undefined
if (output?.type === 'Image' && output.label) {
if (output?.type === 'Image') {
outputs.push({
key: ioKey(node.id, ioName),
nodeId: node.id,
nodeName: node.name,
ioName,
label: output.label,
label: output.label || ioName,
})
}
}

View File

@@ -1,12 +1,12 @@
import { MarkerType, type Edge, type Node } from '@vue-flow/core'
import { ioTypeFor, type GraphFxIoType } from './nodeDefinitions'
import { ioOptionsFor, ioTypeFor, type GraphFxIoType } from './nodeDefinitions'
export type GraphFxIoValue = {
label?: string | null
value?: unknown
output?: string | null
type?: GraphFxIoType
definition?: { type?: GraphFxIoType }
definition?: { type?: GraphFxIoType; enum?: string[]; options?: string[] }
}
export type GraphFxSerializedNode = {
@@ -32,7 +32,25 @@ export type GraphFxPort = {
label: string
type: GraphFxIoType
output?: string | null
connectedDisplay?: string | null
value?: unknown
options?: string[]
previewUrl?: string | null
}
export type GraphFxImageOption = {
id: string
name: string
}
export type GraphFxNodeActions = {
renameNodeId: (nodeId: string, nextId: string) => void
updateIoLabel: (nodeId: string, direction: 'in' | 'out', ioName: string, label: string) => void
updateInputValue: (nodeId: string, ioName: string, value: string | boolean) => void
disconnectInput: (nodeId: string, ioName: string) => void
deleteNode: (nodeId: string) => void
duplicateNode: (nodeId: string) => void
selectInputImage: (nodeId: string, ioName: string, imageId: string) => void | Promise<void>
}
export type GraphFxNodeData = {
@@ -42,6 +60,11 @@ export type GraphFxNodeData = {
outputs: GraphFxPort[]
hasSavedPosition: boolean
raw: GraphFxSerializedNode
actions?: GraphFxNodeActions
imageOptions?: GraphFxImageOption[]
selectedImages?: Record<string, string>
imagePreviews?: Record<string, string>
runtimeReady?: boolean
}
export type GraphFxFlowWarning = {
@@ -89,14 +112,35 @@ function labelFor(name: string, io?: GraphFxIoValue) {
return io?.label || name
}
function imagePreviewUrl(value: unknown): string | null {
if (!value) return null
if (typeof value === 'string') {
if (value.startsWith('data:image/')) return value
if (/^[A-Za-z0-9+/=\r\n]+$/.test(value) && value.length > 80) return `data:image/png;base64,${value.replace(/\s+/g, '')}`
return null
}
if (typeof value === 'object') {
const record = value as { src?: unknown; dataUrl?: unknown; base64?: unknown }
if (typeof record.src === 'string') return imagePreviewUrl(record.src)
if (typeof record.dataUrl === 'string') return imagePreviewUrl(record.dataUrl)
if (typeof record.base64 === 'string') return imagePreviewUrl(record.base64)
}
return null
}
function toPorts(nodeName: string, record: Record<string, GraphFxIoValue> | undefined, direction: 'in' | 'out') {
return Object.entries(record || {}).map(([name, io]) => ({
name,
label: labelFor(name, io),
type: ioTypeFor(nodeName, direction, name, io?.type || io?.definition?.type),
output: direction === 'in' ? io?.output || null : null,
value: direction === 'in' ? io?.value : undefined,
}))
return Object.entries(record || {}).map(([name, io]) => {
const type = ioTypeFor(nodeName, direction, name, io?.type || io?.definition?.type)
return {
name,
label: labelFor(name, io),
type,
output: direction === 'in' ? io?.output || null : null,
value: direction === 'in' ? io?.value : undefined,
options: ioOptionsFor(nodeName, direction, name, io?.definition),
previewUrl: type === 'Image' ? imagePreviewUrl(io?.value) : null,
}
})
}
function savedPosition(item: GraphFxSerializedNode, index: number) {
@@ -105,7 +149,7 @@ function savedPosition(item: GraphFxSerializedNode, index: number) {
return {
hasSavedPosition: hasX && hasY,
position: hasX && hasY ? { x: item.x as number, y: item.y as number } : { x: index * 280, y: 0 },
position: hasX && hasY ? { x: item.x as number, y: item.y as number } : { x: index * 430, y: 0 },
}
}
@@ -189,6 +233,11 @@ export function graphFxToVueFlow(graph: GraphFxSerializedGraph): GraphFxFlowMode
const sourceNode = nodes.find((node) => node.id === source.nodeId)
const sourcePort = sourceNode?.data?.outputs.find((output) => output.name === source.outputName)
const targetNode = nodes.find((node) => node.id === item.node.id)
const targetPort = targetNode?.data?.inputs.find((port) => port.name === inputName)
if (targetPort) {
targetPort.connectedDisplay = `${sourceNode?.data?.name || source.nodeId}.${sourcePort?.label || source.outputName}`
}
const edgeColor = ioTypeColors[sourcePort?.type || 'Unknown']
edges.push({

View File

@@ -0,0 +1,53 @@
export type StoredImage = {
id: string
name: string
dataUrl: string
createdAt: number
}
const IMAGE_LIBRARY_KEY = 'GRAPHFX_UI_IMAGE_LIBRARY'
export function loadImageLibrary(): StoredImage[] {
try {
const raw = localStorage.getItem(IMAGE_LIBRARY_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed)
? parsed.filter((item) => item?.id && item?.name && item?.dataUrl)
: []
} catch {
return []
}
}
export function saveImageLibrary(images: StoredImage[]) {
localStorage.setItem(IMAGE_LIBRARY_KEY, JSON.stringify(images))
}
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error || new Error(`Could not read ${file.name}`))
reader.onload = () => resolve(String(reader.result || ''))
reader.readAsDataURL(file)
})
}
export async function filesToStoredImages(files: FileList | File[]): Promise<StoredImage[]> {
const imageFiles = Array.from(files).filter((file) => file.type.startsWith('image/'))
return Promise.all(imageFiles.map(async (file) => ({
id: crypto.randomUUID(),
name: file.name,
dataUrl: await fileToDataUrl(file),
createdAt: Date.now(),
})))
}
export function loadHtmlImage(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('Could not decode selected image.'))
image.src = dataUrl
})
}

View File

@@ -1,10 +1,10 @@
import type { Edge, Node, Position } from '@vue-flow/core'
import dagre from '@dagrejs/dagre'
const DEFAULT_NODE_WIDTH = 280
const DEFAULT_ROW_HEIGHT = 34
const DEFAULT_HEADER_HEIGHT = 88
const MIN_NODE_HEIGHT = 150
const DEFAULT_NODE_WIDTH = 340
const DEFAULT_ROW_HEIGHT = 44
const DEFAULT_HEADER_HEIGHT = 92
const MIN_NODE_HEIGHT = 170
type LayoutDirection = 'LR' | 'TB'
@@ -42,10 +42,10 @@ export function layoutGraph(nodes: Node[], edges: Edge[], options: LayoutOptions
graph.setDefaultEdgeLabel(() => ({}))
graph.setGraph({
rankdir: direction,
nodesep: 90,
ranksep: 130,
marginx: 40,
marginy: 40,
nodesep: 150,
ranksep: 210,
marginx: 80,
marginy: 80,
})
for (const node of nodes) {

View File

@@ -1,8 +1,10 @@
export type GraphFxIoType = 'Image' | 'Number' | 'String' | 'Boolean' | 'Color' | 'Font' | 'Unknown'
export type GraphFxIoDefinition = GraphFxIoType | { type: GraphFxIoType; options?: string[] }
export type GraphFxNodeDefinition = {
in?: Record<string, GraphFxIoType>
out?: Record<string, GraphFxIoType>
in?: Record<string, GraphFxIoDefinition>
out?: Record<string, GraphFxIoDefinition>
}
const imageDimensions = {
@@ -34,11 +36,18 @@ export const graphFxNodeDefinitions: Record<string, GraphFxNodeDefinition> = {
bg: 'Image',
bgX: 'Number',
bgY: 'Number',
mode: { type: 'String', options: [
'source-over', 'source-in', 'source-out', 'source-atop',
'destination-over', 'destination-in', 'destination-out', 'destination-atop',
'lighter', 'copy', 'xor', 'multiply', 'screen', 'overlay', 'darken', 'lighten',
'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion',
'hue', 'saturation', 'color', 'luminosity',
] },
background: 'Image',
overlay: 'Image',
qr: 'Image',
},
out: { image: 'Image' },
out: canvas2dBase.out,
},
Fill: { in: { ...canvas2dBase.in, height: 'Number', color: 'Color' }, out: canvas2dBase.out },
Flip: { in: { ...canvas2dBase.in, horizontal: 'Boolean', vertical: 'Boolean' }, out: canvas2dBase.out },
@@ -133,5 +142,17 @@ export function ioTypeFor(nodeName: string, direction: 'in' | 'out', ioName: str
return explicit as GraphFxIoType
}
return graphFxNodeDefinitions[nodeName]?.[direction]?.[ioName] || 'Unknown'
const definition = graphFxNodeDefinitions[nodeName]?.[direction]?.[ioName]
if (typeof definition === 'string') return definition as GraphFxIoType
return definition?.type || 'Unknown'
}
export function ioOptionsFor(nodeName: string, direction: 'in' | 'out', ioName: string, fallback?: unknown): string[] {
const fromFallback = fallback && typeof fallback === 'object'
? ((fallback as { enum?: unknown; options?: unknown }).enum || (fallback as { options?: unknown }).options)
: null
if (Array.isArray(fromFallback)) return fromFallback.filter((item): item is string => typeof item === 'string')
const definition = graphFxNodeDefinitions[nodeName]?.[direction]?.[ioName]
return typeof definition === 'object' ? definition.options || [] : []
}

View File

@@ -19,7 +19,7 @@ export const sampleGraph: GraphFxSerializedGraph = [
name: 'Resize',
options: {
in: {
image: { label: 'Image', value: null, output: 'webcam-image' },
image: { label: 'Image', value: null, output: 'webcam-out-image' },
width: { label: 'Width', value: 1280, output: null },
height: { label: 'Height', value: 720, output: null },
},
@@ -35,7 +35,7 @@ export const sampleGraph: GraphFxSerializedGraph = [
name: 'BodySegmentation',
options: {
in: {
image: { label: 'Image', value: null, output: 'resize-preview-image' },
image: { label: 'Image', value: null, output: 'resize-preview-out-image' },
},
out: {
mask: { label: 'Person mask' },
@@ -49,8 +49,8 @@ export const sampleGraph: GraphFxSerializedGraph = [
name: 'GreenScreen',
options: {
in: {
image: { label: 'Image', value: null, output: 'resize-preview-image' },
mask: { label: 'Mask', value: null, output: 'face-mask-mask' },
image: { label: 'Image', value: null, output: 'resize-preview-out-image' },
mask: { label: 'Mask', value: null, output: 'face-mask-out-mask' },
feather: { label: 'Feather', value: 0.25, output: null },
},
out: {
@@ -96,9 +96,9 @@ export const sampleGraph: GraphFxSerializedGraph = [
name: 'Compose',
options: {
in: {
background: { label: 'Background', value: null, output: 'green-screen-image' },
overlay: { label: 'Overlay', value: null, output: 'caption-image' },
qr: { label: 'QR', value: null, output: 'qr-generator-image' },
background: { label: 'Background', value: null, output: 'green-screen-out-image' },
overlay: { label: 'Overlay', value: null, output: 'caption-out-image' },
qr: { label: 'QR', value: null, output: 'qr-generator-out-image' },
},
out: {
image: { label: 'Final print' },
@@ -112,7 +112,7 @@ export const sampleGraph: GraphFxSerializedGraph = [
name: 'Api',
options: {
in: {
image: { label: 'Image', value: null, output: 'compose-final-image' },
image: { label: 'Image', value: null, output: 'compose-final-out-image' },
endpoint: { label: 'Endpoint', value: '/print', output: null },
},
out: {

View File

@@ -0,0 +1,57 @@
import type { GraphFxSerializedGraph } from './graphfxToVueFlow'
export const imageTestGraph: GraphFxSerializedGraph = [
{
node: {
id: 'uploaded-photo',
name: 'Resize',
options: {
in: {
image: { label: 'Uploaded photo', value: null, output: null },
width: { label: 'Width', value: 900, output: null },
height: { label: 'Height', value: 600, output: null },
},
out: {
image: { label: 'Resized photo' },
width: { label: null },
height: { label: null },
},
},
},
},
{
node: {
id: 'mirror-photo',
name: 'Flip',
options: {
in: {
image: { label: 'Image', value: null, output: 'uploaded-photo-out-image' },
horizontal: { label: 'Mirror', value: true, output: null },
vertical: { label: 'Upside down', value: false, output: null },
},
out: {
image: { label: 'Mirrored photo' },
width: { label: null },
height: { label: null },
},
},
},
},
{
node: {
id: 'rotate-photo',
name: 'Rotate',
options: {
in: {
image: { label: 'Image', value: null, output: 'mirror-photo-out-image' },
angle: { label: 'Angle', value: 0, output: null },
},
out: {
image: { label: 'Preview output' },
width: { label: null },
height: { label: null },
},
},
},
},
]

View File

@@ -26,7 +26,7 @@ button {
padding: 0.72rem 1rem;
background: linear-gradient(135deg, #6d7cff, #37d5ff);
color: #07101d;
font-weight: 800;
font-weight: 850;
cursor: pointer;
box-shadow: 0 12px 28px rgb(55 213 255 / 20%);
}
@@ -37,7 +37,16 @@ button.secondary {
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 9%);
}
button.ghost {
border-radius: 9px;
padding: 0.25rem 0.45rem;
background: rgb(255 255 255 / 7%);
color: #dce7ff;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 9%);
}
button:hover { transform: translateY(-1px); }
button:disabled { cursor: not-allowed; opacity: 0.55; transform: none; }
code {
color: #98f5ff;
@@ -49,8 +58,9 @@ code {
#app { height: 100vh; }
.app-shell {
position: relative;
display: grid;
grid-template-columns: 430px 1fr;
grid-template-columns: 420px 1fr;
height: 100vh;
background:
radial-gradient(circle at 15% 10%, rgb(84 101 255 / 18%), transparent 32rem),
@@ -59,16 +69,14 @@ code {
transition: grid-template-columns 180ms ease;
}
.app-shell--collapsed {
grid-template-columns: 0 1fr;
}
.app-shell--collapsed { grid-template-columns: 0 1fr; }
.sidebar {
display: flex;
flex-direction: column;
gap: 0.9rem;
gap: 0.85rem;
min-width: 0;
padding: 0.9rem;
padding: 0.82rem;
border-right: 1px solid rgb(255 255 255 / 8%);
background: rgb(10 14 24 / 94%);
box-shadow: 18px 0 44px rgb(0 0 0 / 28%);
@@ -85,7 +93,7 @@ code {
position: absolute;
z-index: 20;
top: 0.85rem;
left: calc(430px - 0.9rem);
left: calc(420px - 0.9rem);
display: grid;
place-items: center;
width: 1.8rem;
@@ -98,9 +106,7 @@ code {
transition: left 180ms ease;
}
.app-shell--collapsed .sidebar-toggle {
left: 0.45rem;
}
.app-shell--collapsed .sidebar-toggle { left: 0.45rem; }
.savebar {
display: grid;
@@ -120,7 +126,8 @@ code {
}
.savebar input[type="text"],
.panel-card select {
.panel-card select,
.editor-input {
min-width: 0;
width: 100%;
border: 1px solid rgb(255 255 255 / 12%);
@@ -130,10 +137,7 @@ code {
background: #101827;
}
.savebar button {
padding: 0.55rem 0.72rem;
font-size: 0.78rem;
}
.savebar button { padding: 0.55rem 0.72rem; font-size: 0.78rem; }
.tabs {
display: grid;
@@ -160,19 +164,21 @@ code {
.tab-panel {
display: flex;
flex-direction: column;
gap: 0.85rem;
gap: 0.78rem;
min-height: 0;
}
.panel-card {
display: grid;
gap: 0.7rem;
padding: 0.85rem;
gap: 0.68rem;
padding: 0.82rem;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 18px;
background: rgb(255 255 255 / 4%);
}
.quickstart-card { background: rgb(55 213 255 / 7%); }
.control-row,
.section-heading,
.io-action-row {
@@ -198,29 +204,26 @@ code {
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 9%);
}
.toggle.active,
.io-action-row button.active {
.toggle.active {
background: linear-gradient(135deg, #33e1ba, #37d5ff);
color: #07101d;
}
.toolbar {
.toolbar,
.data-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.data-actions { grid-template-columns: 1fr auto auto; }
.toolbar button,
.data-actions button,
.io-action-row button {
.data-actions button {
padding-inline: 0.68rem;
white-space: nowrap;
}
.toolbar--examples button:first-child {
grid-column: auto;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -250,184 +253,64 @@ code {
font-weight: 900;
}
.run-button {
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; }
.run-button { width: 100%; }
.run-button.compact { width: auto; padding: 0.5rem 0.76rem; }
.status-line,
.muted {
.muted,
.hint {
margin: 0;
color: #9eb0cf;
font-size: 0.84rem;
}
.webcam-preview {
width: 100%;
max-height: 150px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 14px;
background: #050913;
object-fit: contain;
}
.hint strong { color: #e8eefc; }
.io-actions {
display: grid;
.runtime-summary {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.io-action-row {
padding: 0.52rem;
.runtime-summary span {
border-radius: 999px;
padding: 0.18rem 0.5rem;
color: #06111c;
background: #9ee66f;
font-size: 0.7rem;
font-weight: 900;
}
.image-grid {
display: grid;
gap: 0.5rem;
max-height: 320px;
overflow: auto;
padding-right: 0.15rem;
}
.image-tile {
display: grid;
grid-template-columns: 72px 1fr;
gap: 0.55rem;
align-items: center;
padding: 0.45rem;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 13px;
border-radius: 14px;
background: rgb(5 9 19 / 58%);
}
.io-action-row div {
min-width: 0;
display: grid;
.image-tile img {
width: 72px;
height: 48px;
border-radius: 10px;
object-fit: cover;
background: #050913;
}
.io-action-row strong,
.io-action-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.io-action-row strong {
color: #f8fbff;
font-size: 0.84rem;
}
.io-action-row small {
color: #8193b3;
font-size: 0.68rem;
}
.image-tile div { min-width: 0; display: grid; gap: 0.25rem; }
.image-tile strong { overflow: hidden; color: #f8fbff; font-size: 0.78rem; text-overflow: ellipsis; white-space: nowrap; }
.image-tile button { justify-self: start; font-size: 0.66rem; }
.output-preview {
display: grid;
@@ -444,17 +327,9 @@ button.danger {
color: #61708a;
}
.output-preview img {
max-width: 100%;
max-height: 320px;
object-fit: contain;
}
.data-actions {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.55rem;
}
.output-preview img { max-width: 100%; max-height: 320px; object-fit: contain; }
.output-preview--compact { min-height: 150px; }
.output-preview--compact img { max-height: 220px; }
.visually-hidden {
position: absolute;
@@ -512,37 +387,21 @@ button.danger {
line-height: 1.5;
}
.json-editor textarea:focus {
.json-editor textarea:focus,
.editor-input:focus,
.graphfx-node input:focus,
.graphfx-node select:focus {
border-color: rgb(55 213 255 / 48%);
box-shadow: 0 0 0 4px rgb(55 213 255 / 10%);
outline: none;
}
.flow-panel {
min-width: 0;
min-height: 0;
}
.flow-panel { min-width: 0; min-height: 0; }
.vue-flow {
width: 100%;
height: 100%;
background: transparent;
}
.vue-flow__edge-path {
stroke: #7da2ff;
stroke-width: 2.5;
}
.vue-flow__edge-textbg {
fill: #101827;
stroke: rgb(255 255 255 / 12%);
}
.vue-flow__edge-text {
fill: #dce7ff;
font-weight: 800;
font-size: 11px;
}
.vue-flow { width: 100%; height: 100%; background: transparent; }
.vue-flow__edge-path { stroke: #7da2ff; stroke-width: 2.5; }
.vue-flow__edge-textbg { fill: #101827; stroke: rgb(255 255 255 / 12%); }
.vue-flow__edge-text { fill: #dce7ff; font-weight: 800; font-size: 11px; }
.vue-flow__controls {
overflow: hidden;
@@ -557,9 +416,61 @@ button.danger {
color: #dce7ff;
}
.context-menu {
position: fixed;
z-index: 60;
display: grid;
gap: 0.55rem;
width: 340px;
max-height: min(540px, calc(100vh - 2rem));
padding: 0.72rem;
border: 1px solid rgb(136 160 255 / 28%);
border-radius: 18px;
background: rgb(12 18 31 / 96%);
box-shadow: 0 28px 80px rgb(0 0 0 / 45%);
backdrop-filter: blur(14px);
}
.context-menu__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.context-menu__head strong { font-size: 0.92rem; }
.context-menu__list {
display: grid;
grid-template-columns: 1fr;
gap: 0.42rem;
max-height: 420px;
overflow-x: hidden;
overflow-y: auto;
}
.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; }
button.danger,
button.ghost.danger {
background: linear-gradient(135deg, #ff6b6b, #ff9f68);
color: #170707;
box-shadow: 0 12px 28px rgb(255 107 107 / 16%);
}
.graphfx-node {
min-width: 276px;
overflow: hidden;
min-width: 340px;
max-width: 380px;
overflow: visible;
border: 1px solid rgb(157 177 255 / 24%);
border-radius: 18px;
background: linear-gradient(180deg, rgb(24 34 55 / 97%), rgb(11 17 29 / 97%));
@@ -569,8 +480,14 @@ button.danger {
}
.graphfx-node__header {
padding: 0.72rem 0.86rem 0.66rem;
position: relative;
display: flex;
align-items: start;
justify-content: space-between;
gap: 0.75rem;
padding: 0.72rem 0.82rem 0.66rem;
border-bottom: 1px solid rgb(255 255 255 / 8%);
border-radius: 18px 18px 0 0;
background:
radial-gradient(circle at top right, rgb(55 213 255 / 16%), transparent 9rem),
rgb(255 255 255 / 3%);
@@ -585,71 +502,93 @@ button.danger {
letter-spacing: -0.015em;
}
.graphfx-node small {
.graphfx-node__id {
display: block;
margin-top: 0.18rem;
max-width: 240px;
overflow: hidden;
color: #8296b8;
width: 235px;
margin-top: 0.22rem;
border: 1px solid transparent;
border-radius: 8px;
padding: 0.18rem 0.28rem;
background: rgb(0 0 0 / 18%);
color: #92a7c8;
font-size: 0.66rem;
text-overflow: ellipsis;
}
.graphfx-node__menu { position: relative; }
.graphfx-node__menu summary {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 999px;
color: #dce7ff;
background: rgb(255 255 255 / 7%);
cursor: pointer;
list-style: none;
}
.graphfx-node__menu summary::-webkit-details-marker { display: none; }
.graphfx-node__menu-popover {
position: absolute;
top: 34px;
right: 0;
z-index: 12;
display: grid;
gap: 0.35rem;
min-width: 130px;
padding: 0.45rem;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 13px;
background: #0d1424;
box-shadow: 0 18px 44px rgb(0 0 0 / 35%);
}
.graphfx-node__menu-popover button { padding: 0.42rem 0.56rem; font-size: 0.72rem; }
.graphfx-node__ports {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
padding: 0.58rem 0.56rem 0.68rem;
gap: 0.58rem;
padding: 0.62rem 0.58rem 0.72rem;
}
.graphfx-node__ports--empty { grid-template-columns: 1fr; }
.graphfx-node__column {
display: grid;
align-content: start;
gap: 0.28rem;
}
.graphfx-node__column { display: grid; align-content: start; gap: 0.34rem; }
.graphfx-node__port {
--io-color: #94a3b8;
position: relative;
display: grid;
min-height: 34px;
padding: 0.36rem 0.48rem;
gap: 0.3rem;
min-height: 58px;
padding: 0.42rem 0.5rem;
border: 1px solid color-mix(in srgb, var(--io-color) 28%, transparent);
border-radius: 11px;
border-radius: 12px;
background:
linear-gradient(90deg, color-mix(in srgb, var(--io-color) 16%, transparent), transparent 46%),
linear-gradient(90deg, color-mix(in srgb, var(--io-color) 16%, transparent), transparent 55%),
rgb(255 255 255 / 4%);
color: #eef4ff;
font-size: 0.76rem;
}
.graphfx-node__port--input {
padding-left: 0.72rem;
text-align: left;
}
.graphfx-node__port--input { padding-left: 0.76rem; text-align: left; }
.graphfx-node__port--output {
padding-right: 0.72rem;
padding-right: 0.76rem;
text-align: right;
background:
linear-gradient(270deg, color-mix(in srgb, var(--io-color) 16%, transparent), transparent 46%),
linear-gradient(270deg, color-mix(in srgb, var(--io-color) 16%, transparent), transparent 55%),
rgb(255 255 255 / 4%);
}
.graphfx-node__port--labeled {
min-height: 44px;
background:
linear-gradient(90deg, color-mix(in srgb, var(--io-color) 26%, transparent), transparent 58%),
rgb(255 255 255 / 6%);
}
.graphfx-node__port--labeled { background: linear-gradient(90deg, color-mix(in srgb, var(--io-color) 24%, transparent), transparent 58%), rgb(255 255 255 / 6%); }
.graphfx-node__port--output.graphfx-node__port--labeled { background: linear-gradient(270deg, color-mix(in srgb, var(--io-color) 24%, transparent), transparent 58%), rgb(255 255 255 / 6%); }
.graphfx-node__port--output.graphfx-node__port--labeled {
background:
linear-gradient(270deg, color-mix(in srgb, var(--io-color) 26%, transparent), transparent 58%),
rgb(255 255 255 / 6%);
.graphfx-node__port-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
}
.graphfx-node__port-head--right { justify-content: flex-end; }
.graphfx-node__port-name {
overflow: hidden;
@@ -662,36 +601,121 @@ button.danger {
white-space: nowrap;
}
.graphfx-node__port-label {
display: inline-block;
width: fit-content;
max-width: 100%;
margin-top: 0.2rem;
overflow: hidden;
.graphfx-node__port-type {
border-radius: 999px;
padding: 0.11rem 0.42rem;
padding: 0.08rem 0.34rem;
background: color-mix(in srgb, var(--io-color) 88%, white 12%);
color: #06101f;
font-size: 0.66rem;
font-size: 0.56rem;
font-weight: 950;
line-height: 1.15;
text-transform: uppercase;
}
.graphfx-node__label-chip {
min-width: 0;
overflow: hidden;
border-radius: 999px;
padding: 0.08rem 0.36rem;
background: color-mix(in srgb, var(--io-color) 22%, transparent);
color: color-mix(in srgb, var(--io-color) 72%, white 28%);
font-size: 0.58rem;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.graphfx-node__port--output .graphfx-node__port-label {
.graphfx-node__label-editor {
display: none;
justify-self: start;
max-width: 100%;
}
.graphfx-node__port:hover .graphfx-node__label-editor,
.graphfx-node__label-editor[open],
.graphfx-node__label-editor:focus-within {
display: block;
}
.graphfx-node__port--output .graphfx-node__label-editor {
justify-self: end;
}
.graphfx-node__port-value {
.graphfx-node__label-editor summary {
width: fit-content;
border-radius: 999px;
padding: 0.12rem 0.38rem;
background: rgb(255 255 255 / 6%);
color: #7f93b7;
cursor: pointer;
font-size: 0.58rem;
font-weight: 850;
list-style: none;
}
.graphfx-node__label-editor summary::-webkit-details-marker { display: none; }
.graphfx-node__label-editor[open] summary {
margin-bottom: 0.25rem;
color: #dce7ff;
}
.graphfx-node__image-preview {
width: 100%;
max-height: 92px;
border: 1px solid color-mix(in srgb, var(--io-color) 34%, transparent);
border-radius: 10px;
background: #050913;
object-fit: contain;
}
.graphfx-node__label-input,
.graphfx-node__value-input {
width: 100%;
min-width: 0;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 9px;
padding: 0.31rem 0.42rem;
background: rgb(5 9 19 / 70%);
color: #eef4ff;
font-size: 0.68rem;
}
.graphfx-node__label-input { color: color-mix(in srgb, var(--io-color) 72%, white 28%); }
.graphfx-node__value-input[type="color"] { height: 31px; padding: 0.14rem; }
.graphfx-node__connected-value {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.32rem;
min-width: 0;
}
.graphfx-node__connected-value span {
overflow: hidden;
color: color-mix(in srgb, var(--io-color) 68%, white 32%);
font-size: 0.66rem;
font-weight: 750;
border-radius: 9px;
padding: 0.32rem 0.42rem;
background: color-mix(in srgb, var(--io-color) 16%, transparent);
color: color-mix(in srgb, var(--io-color) 70%, white 30%);
font-size: 0.68rem;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.graphfx-node__checkbox {
display: flex;
align-items: center;
gap: 0.38rem;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 9px;
padding: 0.31rem 0.42rem;
background: rgb(5 9 19 / 70%);
color: #eef4ff;
font-size: 0.68rem;
font-weight: 800;
}
.graphfx-node__empty {
padding: 0.55rem 0.45rem;
border-radius: 10px;
@@ -700,26 +724,34 @@ button.danger {
font-size: 0.75rem;
}
.graphfx-node__handle {
width: 12px;
height: 12px;
.vue-flow__handle.graphfx-node__handle {
width: 56px !important;
height: 56px !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.graphfx-node__handle::after {
content: '';
position: absolute;
inset: 21px;
border: 2px solid #0a1020;
background: transparent;
border-radius: 999px;
background: #111827;
box-shadow: 0 0 0 2px var(--io-color), 0 0 14px color-mix(in srgb, var(--io-color) 45%, transparent);
}
.graphfx-node__handle--input {
left: -6px;
.graphfx-node__handle:hover::after,
.graphfx-node__handle:focus::after {
inset: 17px;
box-shadow: 0 0 0 3px var(--io-color), 0 0 22px color-mix(in srgb, var(--io-color) 70%, transparent);
}
.graphfx-node__handle--output {
right: -6px;
background: var(--io-color);
}
.graphfx-node__port--connected .graphfx-node__handle--input {
background: var(--io-color);
}
.vue-flow__handle.graphfx-node__handle--input { left: -30px !important; }
.vue-flow__handle.graphfx-node__handle--output { right: -30px !important; }
.graphfx-node__handle--output::after { background: var(--io-color); }
.graphfx-node__port--connected .graphfx-node__handle--input::after { background: var(--io-color); }
.io-type-image { --io-color: #4dd7ff; }
.io-type-number { --io-color: #f6c85f; }