diff --git a/graphfx-ui/src/components/GraphFxNode.vue b/graphfx-ui/src/components/GraphFxNode.vue
index 6f7dccc..ddd510e 100644
--- a/graphfx-ui/src/components/GraphFxNode.vue
+++ b/graphfx-ui/src/components/GraphFxNode.vue
@@ -13,7 +13,7 @@ function typeClass(port: GraphFxPort) {
}
function hasCustomLabel(port: GraphFxPort) {
- return port.label && port.label !== port.name
+ return Boolean(port.label && port.label !== port.name)
}
function labelValue(port: GraphFxPort) {
@@ -121,11 +121,21 @@ function connectedText(port: GraphFxPort) {
{{ port.name }}
- {{ port.label }}
- {{ port.type }}
+
+ {{ port.label }}
+
+
+
-
+
← {{ connectedText(port) }}
@@ -175,18 +185,6 @@ function connectedText(port: GraphFxPort) {
@keydown.stop
/>
-
-
- label
-
-
@@ -200,24 +198,22 @@ function connectedText(port: GraphFxPort) {
:title="`${port.name} · ${port.type}`"
>
- {{ port.type }}
- {{ port.label }}
+
+
+ {{ port.label }}
+
+
{{ port.name }}
-
-
-
- label
-
-
+
4) 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, '')}`
+ const trimmed = value.trim()
+ if (trimmed.startsWith('data:image/')) return trimmed
+ const base64Match = trimmed.match(/(?:base64,)?([A-Za-z0-9+/=\r\n]{40,})$/)
+ if (base64Match && /^[A-Za-z0-9+/=\r\n]+$/.test(base64Match[1])) return `data:image/png;base64,${base64Match[1].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)
+ const record = value as Record
+ for (const key of ['src', 'dataUrl', 'dataURL', 'url', 'href', 'base64', 'data', 'image', 'value']) {
+ const found = imagePreviewUrl(record[key], depth + 1)
+ if (found) return found
+ }
+ for (const nested of Object.values(record)) {
+ const found = imagePreviewUrl(nested, depth + 1)
+ if (found) return found
+ }
}
return null
}
+function normalizedOptions(nodeName: string, direction: 'in' | 'out', name: string, io?: GraphFxIoValue) {
+ const options = ioOptionsFor(nodeName, direction, name, io?.definition)
+ if (!options.length) return []
+ const value = typeof io?.value === 'string' ? io.value : null
+ return value && !options.includes(value) ? [value, ...options] : options
+}
+
function toPorts(nodeName: string, record: Record | undefined, direction: 'in' | 'out') {
return Object.entries(record || {}).map(([name, io]) => {
const type = ioTypeFor(nodeName, direction, name, io?.type || io?.definition?.type)
@@ -137,8 +151,8 @@ function toPorts(nodeName: string, record: Record | unde
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,
+ options: normalizedOptions(nodeName, direction, name, io),
+ previewUrl: imagePreviewUrl(io?.value),
}
})
}
diff --git a/graphfx-ui/src/lib/layout.ts b/graphfx-ui/src/lib/layout.ts
index cba0e7c..6e7b389 100644
--- a/graphfx-ui/src/lib/layout.ts
+++ b/graphfx-ui/src/lib/layout.ts
@@ -1,9 +1,8 @@
import type { Edge, Node, Position } from '@vue-flow/core'
import dagre from '@dagrejs/dagre'
-const DEFAULT_NODE_WIDTH = 340
-const DEFAULT_ROW_HEIGHT = 44
-const DEFAULT_HEADER_HEIGHT = 92
+const DEFAULT_NODE_WIDTH = 380
+const DEFAULT_HEADER_HEIGHT = 62
const MIN_NODE_HEIGHT = 170
type LayoutDirection = 'LR' | 'TB'
@@ -13,10 +12,22 @@ type LayoutOptions = {
useSavedPositions?: boolean
}
+function estimatePortHeight(port: any, direction: 'in' | 'out') {
+ let height = 54
+ if (port?.type === 'Image' && port?.previewUrl) height += 108
+ if (direction === 'in') height += port?.output ? 32 : 36
+ return height
+}
+
+function estimateColumnHeight(ports: any[] | undefined, direction: 'in' | 'out') {
+ if (!Array.isArray(ports) || ports.length === 0) return 44
+ return ports.reduce((total, port) => total + estimatePortHeight(port, direction), 0) + Math.max(0, ports.length - 1) * 7
+}
+
function estimateNodeHeight(node: Node) {
- const inputs = Array.isArray(node.data?.inputs) ? node.data.inputs.length : 0
- const outputs = Array.isArray(node.data?.outputs) ? node.data.outputs.length : 0
- return Math.max(MIN_NODE_HEIGHT, DEFAULT_HEADER_HEIGHT + Math.max(inputs, outputs, 1) * DEFAULT_ROW_HEIGHT)
+ const inputHeight = estimateColumnHeight(node.data?.inputs, 'in')
+ const outputHeight = estimateColumnHeight(node.data?.outputs, 'out')
+ return Math.max(MIN_NODE_HEIGHT, DEFAULT_HEADER_HEIGHT + Math.max(inputHeight, outputHeight) + 26)
}
function targetPosition(direction: LayoutDirection): Position {
@@ -42,10 +53,10 @@ export function layoutGraph(nodes: Node[], edges: Edge[], options: LayoutOptions
graph.setDefaultEdgeLabel(() => ({}))
graph.setGraph({
rankdir: direction,
- nodesep: 150,
- ranksep: 210,
- marginx: 80,
- marginy: 80,
+ nodesep: 190,
+ ranksep: 260,
+ marginx: 100,
+ marginy: 100,
})
for (const node of nodes) {
diff --git a/graphfx-ui/src/style.css b/graphfx-ui/src/style.css
index ba0e6d9..db9fcf7 100644
--- a/graphfx-ui/src/style.css
+++ b/graphfx-ui/src/style.css
@@ -468,7 +468,8 @@ button.ghost.danger {
}
.graphfx-node {
- min-width: 340px;
+ width: 380px;
+ min-width: 380px;
max-width: 380px;
overflow: visible;
border: 1px solid rgb(157 177 255 / 24%);
@@ -601,62 +602,66 @@ button.ghost.danger {
white-space: nowrap;
}
-.graphfx-node__port-type {
+.graphfx-node__type-dot {
+ flex: 0 0 auto;
+ width: 0.55rem;
+ height: 0.55rem;
border-radius: 999px;
- padding: 0.08rem 0.34rem;
- background: color-mix(in srgb, var(--io-color) 88%, white 12%);
- color: #06101f;
- font-size: 0.56rem;
- font-weight: 950;
- text-transform: uppercase;
+ background: var(--io-color);
+ box-shadow: 0 0 0 2px rgb(0 0 0 / 32%), 0 0 12px color-mix(in srgb, var(--io-color) 55%, transparent);
+}
+
+.graphfx-node__label-slot {
+ position: relative;
+ min-width: 0;
+ flex: 1 1 auto;
+ height: 1.26rem;
+}
+
+.graphfx-node__port--output .graphfx-node__label-slot {
+ flex: 0 1 8rem;
}
.graphfx-node__label-chip {
- min-width: 0;
+ position: absolute;
+ inset: 0;
overflow: hidden;
border-radius: 999px;
- padding: 0.08rem 0.36rem;
+ padding: 0.12rem 0.42rem;
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;
+ line-height: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
}
-.graphfx-node__label-editor {
+.graphfx-node__label-inline {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
display: none;
- justify-self: start;
- max-width: 100%;
+ width: 100%;
+ min-width: 3.4rem;
+ border: 1px solid color-mix(in srgb, var(--io-color) 42%, transparent);
+ border-radius: 999px;
+ padding: 0.1rem 0.42rem;
+ background: rgb(4 9 18 / 94%);
+ color: color-mix(in srgb, var(--io-color) 78%, white 22%);
+ font-size: 0.58rem;
+ font-weight: 850;
+ line-height: 1rem;
}
-.graphfx-node__port:hover .graphfx-node__label-editor,
-.graphfx-node__label-editor[open],
-.graphfx-node__label-editor:focus-within {
+.graphfx-node__port:hover .graphfx-node__label-inline,
+.graphfx-node__label-inline:focus {
display: block;
}
-.graphfx-node__port--output .graphfx-node__label-editor {
- justify-self: end;
-}
-
-.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__port:hover .graphfx-node__label-chip:has(+ .graphfx-node__label-inline),
+.graphfx-node__label-inline:focus + .graphfx-node__label-chip {
+ opacity: 0;
}
.graphfx-node__image-preview {
@@ -725,17 +730,19 @@ button.ghost.danger {
}
.vue-flow__handle.graphfx-node__handle {
- width: 56px !important;
- height: 56px !important;
+ top: 50% !important;
+ width: 48px !important;
+ height: 48px !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
+ transform: translateY(-50%) !important;
}
.graphfx-node__handle::after {
content: '';
position: absolute;
- inset: 21px;
+ inset: 17px;
border: 2px solid #0a1020;
border-radius: 999px;
background: #111827;
@@ -744,12 +751,12 @@ button.ghost.danger {
.graphfx-node__handle:hover::after,
.graphfx-node__handle:focus::after {
- inset: 17px;
+ inset: 14px;
box-shadow: 0 0 0 3px var(--io-color), 0 0 22px color-mix(in srgb, var(--io-color) 70%, transparent);
}
-.vue-flow__handle.graphfx-node__handle--input { left: -30px !important; }
-.vue-flow__handle.graphfx-node__handle--output { right: -30px !important; }
+.vue-flow__handle.graphfx-node__handle--input { left: -24px !important; }
+.vue-flow__handle.graphfx-node__handle--output { right: -24px !important; }
.graphfx-node__handle--output::after { background: var(--io-color); }
.graphfx-node__port--connected .graphfx-node__handle--input::after { background: var(--io-color); }