Improve graphfx-ui node UX
This commit is contained in:
@@ -19,6 +19,8 @@ const edges = ref<Edge[]>([])
|
|||||||
const warnings = ref<GraphFxFlowWarning[]>([])
|
const warnings = ref<GraphFxFlowWarning[]>([])
|
||||||
const layoutDirection = ref<'LR' | 'TB'>('LR')
|
const layoutDirection = ref<'LR' | 'TB'>('LR')
|
||||||
const preferSavedPositions = ref(false)
|
const preferSavedPositions = ref(false)
|
||||||
|
const selectedSaveSlot = ref(localStorage.getItem('GRAPHFX_UI_SELECTED_SAVE_SLOT') || 'My first graph')
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const { fitView } = useVueFlow()
|
const { fitView } = useVueFlow()
|
||||||
|
|
||||||
const stats = computed(() => ({
|
const stats = computed(() => ({
|
||||||
@@ -51,6 +53,49 @@ function loadSample() {
|
|||||||
refreshFlow()
|
refreshFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveSlotKey(name = selectedSaveSlot.value) {
|
||||||
|
return `GRAPHFX_UI_SAVE_${name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGraph() {
|
||||||
|
refreshFlow()
|
||||||
|
if (parseError.value) return
|
||||||
|
localStorage.setItem('GRAPHFX_UI_SELECTED_SAVE_SLOT', selectedSaveSlot.value)
|
||||||
|
localStorage.setItem(saveSlotKey(), JSON.stringify(graph.value, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadGraph() {
|
||||||
|
const saved = localStorage.getItem(saveSlotKey())
|
||||||
|
if (!saved) {
|
||||||
|
parseError.value = `No saved graph named "${selectedSaveSlot.value}".`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.setItem('GRAPHFX_UI_SELECTED_SAVE_SLOT', selectedSaveSlot.value)
|
||||||
|
editorText.value = saved
|
||||||
|
refreshFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportGraph() {
|
||||||
|
refreshFlow()
|
||||||
|
if (parseError.value) return
|
||||||
|
const blob = new Blob([JSON.stringify(graph.value, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `${selectedSaveSlot.value || 'graphfx-graph'}.json`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importGraph(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
editorText.value = await file.text()
|
||||||
|
input.value = ''
|
||||||
|
refreshFlow()
|
||||||
|
}
|
||||||
|
|
||||||
function relayout() {
|
function relayout() {
|
||||||
const model = graphFxToVueFlow(graph.value)
|
const model = graphFxToVueFlow(graph.value)
|
||||||
nodes.value = layoutGraph(model.nodes, model.edges, {
|
nodes.value = layoutGraph(model.nodes, model.edges, {
|
||||||
@@ -86,6 +131,15 @@ refreshFlow()
|
|||||||
<button type="button" class="secondary" @click="loadSample">Load sample</button>
|
<button type="button" class="secondary" @click="loadSample">Load sample</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="savebar">
|
||||||
|
<input v-model="selectedSaveSlot" type="text" aria-label="Save slot name" />
|
||||||
|
<button type="button" class="secondary" @click="loadGraph">Load</button>
|
||||||
|
<button type="button" class="secondary" @click="saveGraph">Save</button>
|
||||||
|
<button type="button" class="secondary" @click="fileInput?.click()">Import</button>
|
||||||
|
<button type="button" class="secondary" @click="exportGraph">Export</button>
|
||||||
|
<input ref="fileInput" class="visually-hidden" type="file" accept="application/json,.json" @change="importGraph" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="options">
|
<div class="options">
|
||||||
<label>
|
<label>
|
||||||
Layout
|
Layout
|
||||||
|
|||||||
@@ -7,45 +7,64 @@ const props = defineProps<{
|
|||||||
data: GraphFxNodeData
|
data: GraphFxNodeData
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
function typeClass(port: GraphFxPort) {
|
||||||
|
return `io-type-${port.type.toLowerCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
function valuePreview(port: GraphFxPort) {
|
function valuePreview(port: GraphFxPort) {
|
||||||
if (port.output) return 'connected'
|
if (port.output) return ''
|
||||||
if (port.value === null || port.value === undefined) return 'empty'
|
if (port.value === null || port.value === undefined) return ''
|
||||||
if (typeof port.value === 'string') return port.value.length > 28 ? `${port.value.slice(0, 28)}…` : port.value
|
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 === 'number' || typeof port.value === 'boolean') return String(port.value)
|
||||||
if (typeof port.value === 'object') return 'object'
|
if (typeof port.value === 'object') return 'object'
|
||||||
return String(port.value)
|
return String(port.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasCustomLabel(port: GraphFxPort) {
|
||||||
|
return port.label && port.label !== port.name
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="graphfx-node">
|
<article class="graphfx-node">
|
||||||
<header class="graphfx-node__header">
|
<header class="graphfx-node__header">
|
||||||
<p class="graphfx-node__kind">GraphFX</p>
|
|
||||||
<h3>{{ data.name }}</h3>
|
<h3>{{ data.name }}</h3>
|
||||||
<small>{{ data.id }}</small>
|
<small>{{ data.id }}</small>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="graphfx-node__ports" :class="{ 'graphfx-node__ports--empty': !data.inputs.length && !data.outputs.length }">
|
<section class="graphfx-node__ports" :class="{ 'graphfx-node__ports--empty': !data.inputs.length && !data.outputs.length }">
|
||||||
<div class="graphfx-node__column">
|
<div class="graphfx-node__column graphfx-node__column--inputs">
|
||||||
<p class="graphfx-node__column-title">Inputs</p>
|
<div v-if="!data.inputs.length" class="graphfx-node__empty">no inputs</div>
|
||||||
<div v-if="!data.inputs.length" class="graphfx-node__empty">none</div>
|
<div
|
||||||
<div v-for="port in data.inputs" :key="port.name" class="graphfx-node__port graphfx-node__port--input">
|
v-for="port in data.inputs"
|
||||||
|
:key="port.name"
|
||||||
|
class="graphfx-node__port graphfx-node__port--input"
|
||||||
|
:class="[typeClass(port), { 'graphfx-node__port--connected': port.output, 'graphfx-node__port--labeled': hasCustomLabel(port) }]"
|
||||||
|
:title="`${port.name} · ${port.type}${port.output ? ` · ${port.output}` : ''}`"
|
||||||
|
>
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
:id="getInputHandleId(data.id, port.name)"
|
:id="getInputHandleId(data.id, port.name)"
|
||||||
:position="Position.Left"
|
:position="Position.Left"
|
||||||
class="graphfx-node__handle graphfx-node__handle--input"
|
class="graphfx-node__handle graphfx-node__handle--input"
|
||||||
/>
|
/>
|
||||||
<span class="graphfx-node__port-label">{{ port.label }}</span>
|
<span class="graphfx-node__port-name">{{ port.name }}</span>
|
||||||
<span class="graphfx-node__port-value" :title="String(port.value ?? '')">{{ valuePreview(port) }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="graphfx-node__column graphfx-node__column--outputs">
|
<div class="graphfx-node__column graphfx-node__column--outputs">
|
||||||
<p class="graphfx-node__column-title">Outputs</p>
|
<div v-if="!data.outputs.length" class="graphfx-node__empty">no outputs</div>
|
||||||
<div v-if="!data.outputs.length" class="graphfx-node__empty">none</div>
|
<div
|
||||||
<div v-for="port in data.outputs" :key="port.name" class="graphfx-node__port graphfx-node__port--output">
|
v-for="port in data.outputs"
|
||||||
<span class="graphfx-node__port-label">{{ port.label }}</span>
|
:key="port.name"
|
||||||
|
class="graphfx-node__port graphfx-node__port--output"
|
||||||
|
: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>
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
:id="getOutputHandleId(data.id, port.name)"
|
:id="getOutputHandleId(data.id, port.name)"
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { Edge, Node } from '@vue-flow/core'
|
import { MarkerType, type Edge, type Node } from '@vue-flow/core'
|
||||||
|
import { ioTypeFor, type GraphFxIoType } from './nodeDefinitions'
|
||||||
|
|
||||||
export type GraphFxIoValue = {
|
export type GraphFxIoValue = {
|
||||||
label?: string | null
|
label?: string | null
|
||||||
value?: unknown
|
value?: unknown
|
||||||
output?: string | null
|
output?: string | null
|
||||||
|
type?: GraphFxIoType
|
||||||
|
definition?: { type?: GraphFxIoType }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GraphFxSerializedNode = {
|
export type GraphFxSerializedNode = {
|
||||||
@@ -27,7 +30,7 @@ export type GraphFxSerializedGraph = GraphFxSerializedNode[]
|
|||||||
export type GraphFxPort = {
|
export type GraphFxPort = {
|
||||||
name: string
|
name: string
|
||||||
label: string
|
label: string
|
||||||
type?: string
|
type: GraphFxIoType
|
||||||
output?: string | null
|
output?: string | null
|
||||||
value?: unknown
|
value?: unknown
|
||||||
}
|
}
|
||||||
@@ -63,14 +66,25 @@ export function getOutputHandleId(nodeId: string, outputName: string) {
|
|||||||
return `${nodeId}:out:${outputName}`
|
return `${nodeId}:out:${outputName}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ioTypeColors: Record<GraphFxIoType, string> = {
|
||||||
|
Image: '#4dd7ff',
|
||||||
|
Number: '#f6c85f',
|
||||||
|
String: '#9ee66f',
|
||||||
|
Boolean: '#c084fc',
|
||||||
|
Color: '#ff78b7',
|
||||||
|
Font: '#fb923c',
|
||||||
|
Unknown: '#94a3b8',
|
||||||
|
}
|
||||||
|
|
||||||
function labelFor(name: string, io?: GraphFxIoValue) {
|
function labelFor(name: string, io?: GraphFxIoValue) {
|
||||||
return io?.label || name
|
return io?.label || name
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPorts(record: Record<string, GraphFxIoValue> | undefined, direction: 'in' | 'out') {
|
function toPorts(nodeName: string, record: Record<string, GraphFxIoValue> | undefined, direction: 'in' | 'out') {
|
||||||
return Object.entries(record || {}).map(([name, io]) => ({
|
return Object.entries(record || {}).map(([name, io]) => ({
|
||||||
name,
|
name,
|
||||||
label: labelFor(name, io),
|
label: labelFor(name, io),
|
||||||
|
type: ioTypeFor(nodeName, direction, name, io?.type || io?.definition?.type),
|
||||||
output: direction === 'in' ? io?.output || null : null,
|
output: direction === 'in' ? io?.output || null : null,
|
||||||
value: direction === 'in' ? io?.value : undefined,
|
value: direction === 'in' ? io?.value : undefined,
|
||||||
}))
|
}))
|
||||||
@@ -121,8 +135,8 @@ export function graphFxToVueFlow(graph: GraphFxSerializedGraph): GraphFxFlowMode
|
|||||||
.filter((item) => item?.node?.id && item.node.name)
|
.filter((item) => item?.node?.id && item.node.name)
|
||||||
.map((item, index) => {
|
.map((item, index) => {
|
||||||
const { hasSavedPosition, position } = savedPosition(item, index)
|
const { hasSavedPosition, position } = savedPosition(item, index)
|
||||||
const inputs = toPorts(item.node.options?.in, 'in')
|
const inputs = toPorts(item.node.name, item.node.options?.in, 'in')
|
||||||
const outputs = toPorts(item.node.options?.out, 'out')
|
const outputs = toPorts(item.node.name, item.node.options?.out, 'out')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.node.id,
|
id: item.node.id,
|
||||||
@@ -159,15 +173,20 @@ export function graphFxToVueFlow(graph: GraphFxSerializedGraph): GraphFxFlowMode
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceNode = nodes.find((node) => node.id === source.nodeId)
|
||||||
|
const sourcePort = sourceNode?.data?.outputs.find((output) => output.name === source.outputName)
|
||||||
|
const edgeColor = ioTypeColors[sourcePort?.type || 'Unknown']
|
||||||
|
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `${source.nodeId}:${source.outputName}->${item.node.id}:${inputName}`,
|
id: `${source.nodeId}:${source.outputName}->${item.node.id}:${inputName}`,
|
||||||
source: source.nodeId,
|
source: source.nodeId,
|
||||||
target: item.node.id,
|
target: item.node.id,
|
||||||
sourceHandle: getOutputHandleId(source.nodeId, source.outputName),
|
sourceHandle: getOutputHandleId(source.nodeId, source.outputName),
|
||||||
targetHandle: getInputHandleId(item.node.id, inputName),
|
targetHandle: getInputHandleId(item.node.id, inputName),
|
||||||
label: inputName,
|
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: false,
|
animated: false,
|
||||||
|
style: { stroke: edgeColor, strokeWidth: 2.8 },
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
137
graphfx-ui/src/lib/nodeDefinitions.ts
Normal file
137
graphfx-ui/src/lib/nodeDefinitions.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
export type GraphFxIoType = 'Image' | 'Number' | 'String' | 'Boolean' | 'Color' | 'Font' | 'Unknown'
|
||||||
|
|
||||||
|
export type GraphFxNodeDefinition = {
|
||||||
|
in?: Record<string, GraphFxIoType>
|
||||||
|
out?: Record<string, GraphFxIoType>
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageDimensions = {
|
||||||
|
image: 'Image',
|
||||||
|
width: 'Number',
|
||||||
|
height: 'Number',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const canvas2dBase = {
|
||||||
|
in: { image: 'Image', width: 'Number' },
|
||||||
|
out: imageDimensions,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const webglBase = {
|
||||||
|
in: { image: 'Image' },
|
||||||
|
out: imageDimensions,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const graphFxNodeDefinitions: Record<string, GraphFxNodeDefinition> = {
|
||||||
|
Webcam: { out: { image: 'Image', width: 'Number', height: 'Number', camera: 'String' } },
|
||||||
|
Resize: { in: { ...canvas2dBase.in, height: 'Number' }, out: canvas2dBase.out },
|
||||||
|
Compose: {
|
||||||
|
in: {
|
||||||
|
...canvas2dBase.in,
|
||||||
|
height: 'Number',
|
||||||
|
fg: 'Image',
|
||||||
|
fgX: 'Number',
|
||||||
|
fgY: 'Number',
|
||||||
|
bg: 'Image',
|
||||||
|
bgX: 'Number',
|
||||||
|
bgY: 'Number',
|
||||||
|
background: 'Image',
|
||||||
|
overlay: 'Image',
|
||||||
|
qr: 'Image',
|
||||||
|
},
|
||||||
|
out: { image: 'Image' },
|
||||||
|
},
|
||||||
|
Fill: { in: { ...canvas2dBase.in, height: 'Number', color: 'Color' }, out: canvas2dBase.out },
|
||||||
|
Flip: { in: { ...canvas2dBase.in, horizontal: 'Boolean', vertical: 'Boolean' }, out: canvas2dBase.out },
|
||||||
|
Rotate: { in: { ...canvas2dBase.in, angle: 'Number' }, out: canvas2dBase.out },
|
||||||
|
Text: {
|
||||||
|
in: {
|
||||||
|
...canvas2dBase.in,
|
||||||
|
text: 'String',
|
||||||
|
fontSize: 'Number',
|
||||||
|
fontStyle: 'String',
|
||||||
|
font: 'Font',
|
||||||
|
color: 'Color',
|
||||||
|
x: 'Number',
|
||||||
|
y: 'Number',
|
||||||
|
},
|
||||||
|
out: canvas2dBase.out,
|
||||||
|
},
|
||||||
|
EmptySpace: { out: { w: 'Number', h: 'Number', top: 'Number', right: 'Number', bottom: 'Number', left: 'Number' } },
|
||||||
|
|
||||||
|
BrightnessContrast: { in: { ...webglBase.in, brightness: 'Number', contrast: 'Number' }, out: webglBase.out },
|
||||||
|
HSV: { in: { ...webglBase.in, hue: 'Number', saturation: 'Number', value: 'Number' }, out: webglBase.out },
|
||||||
|
Sepia: { in: { ...webglBase.in, amount: 'Number' }, out: webglBase.out },
|
||||||
|
GreenScreen: {
|
||||||
|
in: {
|
||||||
|
...webglBase.in,
|
||||||
|
mask: 'Image',
|
||||||
|
feather: 'Number',
|
||||||
|
balance: 'Number',
|
||||||
|
screen: 'Color',
|
||||||
|
screenWeight: 'Number',
|
||||||
|
clipBlack: 'Number',
|
||||||
|
clipWhite: 'Number',
|
||||||
|
},
|
||||||
|
out: webglBase.out,
|
||||||
|
},
|
||||||
|
Channels: { in: { ...webglBase.in, red: 'Number', green: 'Number', blue: 'Number', alpha: 'Number', amount: 'Number' }, out: webglBase.out },
|
||||||
|
GradientMap: { in: { ...webglBase.in, amount: 'Number', map: 'Image' }, out: webglBase.out },
|
||||||
|
ColorLookupTable: { in: { ...webglBase.in, amount: 'Number', map: 'Image' }, out: webglBase.out },
|
||||||
|
Vignette: { in: { ...webglBase.in, amount: 'Number', size: 'Number' }, out: webglBase.out },
|
||||||
|
Blur: { in: { ...webglBase.in, radius: 'Number', sigma: 'Number', passes: 'Number' }, out: webglBase.out },
|
||||||
|
SoftEdge: { in: { ...webglBase.in, radius: 'Number', sigma: 'Number', passes: 'Number' }, out: webglBase.out },
|
||||||
|
Edge: { in: { ...webglBase.in, dx: 'Number', dy: 'Number' }, out: webglBase.out },
|
||||||
|
KernelPreset: { in: { ...webglBase.in, preset: 'String', passes: 'Number', multiplier: 'Number' }, out: webglBase.out },
|
||||||
|
Levels: { in: { ...webglBase.in, shadow: 'Number', gamma: 'Number', highlight: 'Number', dark: 'Number', light: 'Number' }, out: webglBase.out },
|
||||||
|
LevelsAuto: { in: webglBase.in, out: webglBase.out },
|
||||||
|
Tint: { in: { ...webglBase.in, amount: 'Number', color: 'Color' }, out: webglBase.out },
|
||||||
|
|
||||||
|
BodySegmentation: { in: { image: 'Image', maskBlurAmount: 'Number', foregroundThreshold: 'Number' }, out: imageDimensions },
|
||||||
|
BodyPartSegmentation: { in: { image: 'Image', maskBlurAmount: 'Number', foregroundThreshold: 'Number' }, out: imageDimensions },
|
||||||
|
FaceFeaturePosition: {
|
||||||
|
in: { image: 'Image', modelUrl: 'String' },
|
||||||
|
out: {
|
||||||
|
image: 'Image',
|
||||||
|
faceX: 'Number',
|
||||||
|
faceY: 'Number',
|
||||||
|
faceWidth: 'Number',
|
||||||
|
faceHeight: 'Number',
|
||||||
|
rightEyeX: 'Number',
|
||||||
|
rightEyeY: 'Number',
|
||||||
|
leftEyeX: 'Number',
|
||||||
|
leftEyeY: 'Number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Numbers: { in: { x: 'Number', y: 'Number', z: 'Number' }, out: { x: 'Number', y: 'Number', z: 'Number' } },
|
||||||
|
NumberBinaryOperation: { in: { x: 'Number', y: 'Number', operation: 'String' }, out: { result: 'Number' } },
|
||||||
|
Font: { in: { fontSize: 'Number', fontStyle: 'String', font: 'Font' }, out: { fontSize: 'Number', fontStyle: 'String', font: 'Font' } },
|
||||||
|
Disable: { in: { image: 'Image', disabled: 'Boolean' }, out: imageDimensions },
|
||||||
|
Api: {
|
||||||
|
in: Object.fromEntries([...Array(8)].flatMap((_, i) => [[`name${i}`, 'String'], [`image${i}`, 'Image']]).concat([['image', 'Image'], ['endpoint', 'String']])) as Record<string, GraphFxIoType>,
|
||||||
|
out: { image: 'Image', response: 'String' },
|
||||||
|
},
|
||||||
|
ApiJson: {
|
||||||
|
in: Object.fromEntries([...Array(8)].map((_, i) => [`propertyPath${i}`, 'String']).concat([['url', 'String']])) as Record<string, GraphFxIoType>,
|
||||||
|
out: Object.fromEntries([...Array(8)].map((_, i) => [`value${i}`, 'String'])) as Record<string, GraphFxIoType>,
|
||||||
|
},
|
||||||
|
Eval: {
|
||||||
|
in: Object.fromEntries([['image', 'Image'], ...Array.from({ length: 8 }, (_, i) => [`i${i}`, 'Number']), ['script', 'String']]) as Record<string, GraphFxIoType>,
|
||||||
|
out: { image: 'Image', result: 'Number', resultString: 'String' },
|
||||||
|
},
|
||||||
|
QRCodeGenerator: { in: { text: 'String', size: 'Number', correction: 'String', margin: 'Number' }, out: { image: 'Image', size: 'Number', margin: 'Number' } },
|
||||||
|
QRCodeDetector: { in: { image: 'Image', filter: 'String' }, out: { image: 'Image', rotation: 'Number', x: 'Number', y: 'Number', size: 'Number' } },
|
||||||
|
MarkerDetector: { in: { image: 'Image', markerId: 'Number', pivotPoint: 'String' }, out: { image: 'Image', markerId: 'Number', x: 'Number', y: 'Number', rotation: 'Number', x1: 'Number', y1: 'Number', x2: 'Number', y2: 'Number', x3: 'Number', y3: 'Number', x4: 'Number', y4: 'Number' } },
|
||||||
|
WaitForAll: { in: { image0: 'Image', image1: 'Image' }, out: { image0: 'Image', image1: 'Image' } },
|
||||||
|
Base64ToImg: { in: { base64: 'String' }, out: imageDimensions },
|
||||||
|
ImgToBase64: { in: { image: 'Image', type: 'String' }, out: { base64: 'String' } },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ioTypeFor(nodeName: string, direction: 'in' | 'out', ioName: string, fallback?: unknown): GraphFxIoType {
|
||||||
|
const explicit = typeof fallback === 'string' ? fallback : undefined
|
||||||
|
if (explicit && ['Image', 'Number', 'String', 'Boolean', 'Color', 'Font'].includes(explicit)) {
|
||||||
|
return explicit as GraphFxIoType
|
||||||
|
}
|
||||||
|
|
||||||
|
return graphFxNodeDefinitions[nodeName]?.[direction]?.[ioName] || 'Unknown'
|
||||||
|
}
|
||||||
@@ -112,6 +112,39 @@ code {
|
|||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.savebar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr repeat(4, auto);
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.62rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgb(255 255 255 / 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.savebar input[type="text"] {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid rgb(255 255 255 / 12%);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.55rem 0.68rem;
|
||||||
|
color: #f5f8ff;
|
||||||
|
background: #101827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.savebar button {
|
||||||
|
padding: 0.55rem 0.62rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.options {
|
.options {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.72rem;
|
gap: 0.72rem;
|
||||||
@@ -266,97 +299,136 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.graphfx-node {
|
.graphfx-node {
|
||||||
min-width: 280px;
|
min-width: 276px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgb(157 177 255 / 30%);
|
border: 1px solid rgb(157 177 255 / 24%);
|
||||||
border-radius: 20px;
|
border-radius: 18px;
|
||||||
background: linear-gradient(180deg, rgb(28 39 63 / 96%), rgb(13 20 34 / 96%));
|
background: linear-gradient(180deg, rgb(24 34 55 / 97%), rgb(11 17 29 / 97%));
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 18px 48px rgb(0 0 0 / 34%),
|
0 18px 48px rgb(0 0 0 / 34%),
|
||||||
inset 0 1px 0 rgb(255 255 255 / 8%);
|
inset 0 1px 0 rgb(255 255 255 / 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.graphfx-node__header {
|
.graphfx-node__header {
|
||||||
padding: 0.9rem 1rem 0.75rem;
|
padding: 0.72rem 0.86rem 0.66rem;
|
||||||
border-bottom: 1px solid rgb(255 255 255 / 8%);
|
border-bottom: 1px solid rgb(255 255 255 / 8%);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top right, rgb(55 213 255 / 20%), transparent 10rem),
|
radial-gradient(circle at top right, rgb(55 213 255 / 16%), transparent 9rem),
|
||||||
rgb(255 255 255 / 3%);
|
rgb(255 255 255 / 3%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.graphfx-node__kind {
|
|
||||||
margin: 0 0 0.18rem;
|
|
||||||
color: #33e1ba;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 950;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.graphfx-node h3 {
|
.graphfx-node h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1.05rem;
|
font-size: 1.02rem;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graphfx-node small {
|
.graphfx-node small {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.18rem;
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #8fa4c7;
|
color: #8296b8;
|
||||||
font-size: 0.7rem;
|
font-size: 0.66rem;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graphfx-node__ports {
|
.graphfx-node__ports {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 0.55rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 0.6rem 0.85rem;
|
padding: 0.58rem 0.56rem 0.68rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graphfx-node__ports--empty { grid-template-columns: 1fr; }
|
.graphfx-node__ports--empty { grid-template-columns: 1fr; }
|
||||||
|
|
||||||
.graphfx-node__column-title {
|
.graphfx-node__column {
|
||||||
margin: 0 0 0.35rem;
|
display: grid;
|
||||||
padding: 0 0.4rem;
|
align-content: start;
|
||||||
color: #7f93b6;
|
gap: 0.28rem;
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 950;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.graphfx-node__port {
|
.graphfx-node__port {
|
||||||
|
--io-color: #94a3b8;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.08rem;
|
min-height: 34px;
|
||||||
min-height: 31px;
|
padding: 0.36rem 0.48rem;
|
||||||
padding: 0.38rem 0.45rem;
|
border: 1px solid color-mix(in srgb, var(--io-color) 28%, transparent);
|
||||||
border-radius: 10px;
|
border-radius: 11px;
|
||||||
background: rgb(255 255 255 / 5%);
|
background:
|
||||||
color: #dbe7ff;
|
linear-gradient(90deg, color-mix(in srgb, var(--io-color) 16%, transparent), transparent 46%),
|
||||||
|
rgb(255 255 255 / 4%);
|
||||||
|
color: #eef4ff;
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graphfx-node__port + .graphfx-node__port { margin-top: 0.25rem; }
|
.graphfx-node__port--input {
|
||||||
|
padding-left: 0.72rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.graphfx-node__port--input { padding-left: 0.62rem; }
|
.graphfx-node__port--output {
|
||||||
.graphfx-node__port--output { padding-right: 0.62rem; text-align: right; }
|
padding-right: 0.72rem;
|
||||||
|
text-align: right;
|
||||||
|
background:
|
||||||
|
linear-gradient(270deg, color-mix(in srgb, var(--io-color) 16%, transparent), transparent 46%),
|
||||||
|
rgb(255 255 255 / 4%);
|
||||||
|
}
|
||||||
|
|
||||||
.graphfx-node__port-label {
|
.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--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-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
color: #f8fbff;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 950;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.08;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-weight: 850;
|
}
|
||||||
|
|
||||||
|
.graphfx-node__port-label {
|
||||||
|
display: inline-block;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.11rem 0.42rem;
|
||||||
|
background: color-mix(in srgb, var(--io-color) 88%, white 12%);
|
||||||
|
color: #06101f;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.15;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphfx-node__port--output .graphfx-node__port-label {
|
||||||
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graphfx-node__port-value {
|
.graphfx-node__port-value {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #8397ba;
|
color: color-mix(in srgb, var(--io-color) 68%, white 32%);
|
||||||
font-size: 0.64rem;
|
font-size: 0.66rem;
|
||||||
|
font-weight: 750;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -370,18 +442,30 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.graphfx-node__handle {
|
.graphfx-node__handle {
|
||||||
width: 11px;
|
width: 12px;
|
||||||
height: 11px;
|
height: 12px;
|
||||||
border: 2px solid #0c1322;
|
border: 2px solid #0a1020;
|
||||||
background: #33e1ba;
|
background: transparent;
|
||||||
|
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 {
|
.graphfx-node__handle--input {
|
||||||
left: -6px;
|
left: -6px;
|
||||||
background: #7da2ff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.graphfx-node__handle--output {
|
.graphfx-node__handle--output {
|
||||||
right: -6px;
|
right: -6px;
|
||||||
background: #33e1ba;
|
background: var(--io-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.graphfx-node__port--connected .graphfx-node__handle--input {
|
||||||
|
background: var(--io-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.io-type-image { --io-color: #4dd7ff; }
|
||||||
|
.io-type-number { --io-color: #f6c85f; }
|
||||||
|
.io-type-string { --io-color: #9ee66f; }
|
||||||
|
.io-type-boolean { --io-color: #c084fc; }
|
||||||
|
.io-type-color { --io-color: #ff78b7; }
|
||||||
|
.io-type-font { --io-color: #fb923c; }
|
||||||
|
.io-type-unknown { --io-color: #94a3b8; }
|
||||||
|
|||||||
Reference in New Issue
Block a user