Improve GraphFX UI editing and image previews
This commit is contained in:
@@ -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
8
graphfx-ui/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
README.md
|
||||
npm-debug.log*
|
||||
.vite
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
53
graphfx-ui/src/lib/imageLibrary.ts
Normal file
53
graphfx-ui/src/lib/imageLibrary.ts
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || [] : []
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
57
graphfx-ui/src/lib/testGraph.ts
Normal file
57
graphfx-ui/src/lib/testGraph.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user