Add image IO inspector and measured layout

This commit is contained in:
2026-06-19 10:48:57 +02:00
parent 449a7aa071
commit 543e1f5171
5 changed files with 206 additions and 10 deletions

View File

@@ -7,7 +7,7 @@ import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css' import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css' import '@vue-flow/controls/dist/style.css'
import GraphFxNode from './components/GraphFxNode.vue' import GraphFxNode from './components/GraphFxNode.vue'
import { countSavedPositions, graphFxToVueFlow, parseGraphFxJson, type GraphFxFlowWarning, type GraphFxSerializedGraph } from './lib/graphfxToVueFlow' import { countSavedPositions, graphFxToVueFlow, parseGraphFxJson, type GraphFxFlowWarning, type GraphFxImageInspection, type GraphFxSerializedGraph } from './lib/graphfxToVueFlow'
import { layoutGraph } from './lib/layout' import { layoutGraph } from './lib/layout'
import { coerceIoValue, createSerializedNode, nodeCatalog, removeNodeAndConnections } from './lib/editorHelpers' import { coerceIoValue, createSerializedNode, nodeCatalog, removeNodeAndConnections } from './lib/editorHelpers'
import { ioTypeFor } from './lib/nodeDefinitions' import { ioTypeFor } from './lib/nodeDefinitions'
@@ -46,6 +46,7 @@ const runtimeStatus = ref('Graph not running')
const runtimeError = ref<string | null>(null) const runtimeError = ref<string | null>(null)
const selectedOutputKey = ref('') const selectedOutputKey = ref('')
const outputPreviewUrl = ref<string | null>(null) const outputPreviewUrl = ref<string | null>(null)
const imageInspector = ref<GraphFxImageInspection | null>(null)
let unsubscribeOutput: (() => void) | null = null let unsubscribeOutput: (() => void) | null = null
let unsubscribeRuntimeOutputPreviews: Array<() => void> = [] let unsubscribeRuntimeOutputPreviews: Array<() => void> = []
@@ -76,6 +77,11 @@ const nodeActions = {
deleteNode, deleteNode,
duplicateNode, duplicateNode,
selectInputImage, selectInputImage,
inspectImage,
}
function inspectImage(inspection: GraphFxImageInspection) {
imageInspector.value = inspection
} }
function loadImageSelections() { function loadImageSelections() {
@@ -191,8 +197,33 @@ function refreshFromGraph(options: { fit?: boolean; layout?: boolean } = {}) {
if (options.fit) nextTick(() => fitView({ padding: 0.2, duration: 250 })) if (options.fit) nextTick(() => fitView({ padding: 0.2, duration: 250 }))
} }
function applyAutoLayout() { function measureNodeDimensions() {
refreshFromGraph({ layout: true, fit: true }) const dimensions: Record<string, { width: number; height: number }> = {}
for (const node of nodes.value) {
const element = document.querySelector(`.vue-flow__node[data-id="${CSS.escape(node.id)}"]`) as HTMLElement | null
const rect = element?.getBoundingClientRect()
if (rect?.width && rect?.height) {
dimensions[node.id] = { width: rect.width, height: rect.height }
}
}
return dimensions
}
async function applyAutoLayout() {
refreshFlow()
await nextTick()
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
const dimensions = measureNodeDimensions()
const model = graphFxToVueFlow(graph.value)
const flowNodes = layoutGraph(model.nodes, model.edges, { direction: 'LR', useSavedPositions: false, dimensions })
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)
}
}
refreshFromGraph({ fit: true })
} }
function fitGraph() { function fitGraph() {
@@ -717,7 +748,7 @@ refreshFlow()
@edge-double-click="onEdgeDoubleClick" @edge-double-click="onEdgeDoubleClick"
> >
<template #node-graphfx="nodeProps"> <template #node-graphfx="nodeProps">
<GraphFxNode v-bind="nodeProps" /> <GraphFxNode v-bind="nodeProps" @inspect-image="inspectImage" />
</template> </template>
<Background pattern-color="#2f3a54" :gap="24" /> <Background pattern-color="#2f3a54" :gap="24" />
<Controls /> <Controls />
@@ -750,5 +781,19 @@ refreshFlow()
</button> </button>
</div> </div>
</div> </div>
<div v-if="imageInspector" class="image-inspector" role="dialog" aria-modal="true" @click="imageInspector = null">
<article class="image-inspector__panel" @click.stop>
<header>
<div>
<strong>{{ imageInspector.nodeName }}.{{ imageInspector.ioName }}</strong>
<span>{{ imageInspector.direction === 'in' ? 'input' : 'output' }} · {{ imageInspector.label }}</span>
</div>
<button type="button" class="ghost" @click="imageInspector = null">×</button>
</header>
<img :src="imageInspector.url" :alt="`${imageInspector.nodeName}.${imageInspector.ioName}`" />
<textarea readonly :value="imageInspector.url" spellcheck="false" />
</article>
</div>
</main> </main>
</template> </template>

View File

@@ -8,6 +8,17 @@ const props = defineProps<{
data: GraphFxNodeData data: GraphFxNodeData
}>() }>()
const emit = defineEmits<{
'inspect-image': [inspection: {
nodeId: string
nodeName: string
direction: 'in' | 'out'
ioName: string
label: string
url: string
}]
}>()
function typeClass(port: GraphFxPort) { function typeClass(port: GraphFxPort) {
return `io-type-${port.type.toLowerCase()}` return `io-type-${port.type.toLowerCase()}`
} }
@@ -75,6 +86,19 @@ function inputType(port: GraphFxPort) {
function connectedText(port: GraphFxPort) { function connectedText(port: GraphFxPort) {
return port.connectedDisplay || port.output || 'connected' return port.connectedDisplay || port.output || 'connected'
} }
function inspectImage(direction: 'in' | 'out', port: GraphFxPort) {
const url = previewUrl(direction, port)
if (!url) return
emit('inspect-image', {
nodeId: props.data.id,
nodeName: props.data.name,
direction,
ioName: port.name,
label: port.label || port.name,
url,
})
}
</script> </script>
<template> <template>
@@ -135,7 +159,10 @@ function connectedText(port: GraphFxPort) {
<span class="graphfx-node__type-dot" :title="port.type" aria-hidden="true"></span> <span class="graphfx-node__type-dot" :title="port.type" aria-hidden="true"></span>
</div> </div>
<img v-if="previewUrl('in', port)" class="graphfx-node__image-preview" :src="previewUrl('in', port)" :alt="`${port.name} preview`" /> <button v-if="previewUrl('in', port)" type="button" class="graphfx-node__image-preview nodrag" :title="`Inspect ${port.name}`" @click.stop="inspectImage('in', port)" @pointerdown.stop>
<img :src="previewUrl('in', port)" :alt="`${port.name} preview`" />
<span>inspect</span>
</button>
<div v-if="port.output" class="graphfx-node__connected-value nodrag" @pointerdown.stop> <div v-if="port.output" class="graphfx-node__connected-value nodrag" @pointerdown.stop>
<span :title="port.output"> {{ connectedText(port) }}</span> <span :title="port.output"> {{ connectedText(port) }}</span>
@@ -213,7 +240,11 @@ function connectedText(port: GraphFxPort) {
<span class="graphfx-node__port-name">{{ port.name }}</span> <span class="graphfx-node__port-name">{{ port.name }}</span>
</div> </div>
<img v-if="previewUrl('out', port)" class="graphfx-node__image-preview" :src="previewUrl('out', port)" :alt="`${port.name} preview`" /> <button v-if="previewUrl('out', port)" type="button" class="graphfx-node__image-preview nodrag" :title="`Inspect ${port.name}`" @click.stop="inspectImage('out', port)" @pointerdown.stop>
<img :src="previewUrl('out', port)" :alt="`${port.name} preview`" />
<span>inspect</span>
</button>
<div v-else-if="port.type === 'Image'" class="graphfx-node__image-empty">no image yet</div>
<Handle <Handle
type="source" type="source"

View File

@@ -43,6 +43,15 @@ export type GraphFxImageOption = {
name: string name: string
} }
export type GraphFxImageInspection = {
nodeId: string
nodeName: string
direction: 'in' | 'out'
ioName: string
label: string
url: string
}
export type GraphFxNodeActions = { export type GraphFxNodeActions = {
renameNodeId: (nodeId: string, nextId: string) => void renameNodeId: (nodeId: string, nextId: string) => void
updateIoLabel: (nodeId: string, direction: 'in' | 'out', ioName: string, label: string) => void updateIoLabel: (nodeId: string, direction: 'in' | 'out', ioName: string, label: string) => void
@@ -51,6 +60,7 @@ export type GraphFxNodeActions = {
deleteNode: (nodeId: string) => void deleteNode: (nodeId: string) => void
duplicateNode: (nodeId: string) => void duplicateNode: (nodeId: string) => void
selectInputImage: (nodeId: string, ioName: string, imageId: string) => void | Promise<void> selectInputImage: (nodeId: string, ioName: string, imageId: string) => void | Promise<void>
inspectImage: (inspection: GraphFxImageInspection) => void
} }
export type GraphFxNodeData = { export type GraphFxNodeData = {

View File

@@ -10,6 +10,7 @@ type LayoutDirection = 'LR' | 'TB'
type LayoutOptions = { type LayoutOptions = {
direction?: LayoutDirection direction?: LayoutDirection
useSavedPositions?: boolean useSavedPositions?: boolean
dimensions?: Record<string, { width: number; height: number }>
} }
function estimatePortHeight(port: any, direction: 'in' | 'out') { function estimatePortHeight(port: any, direction: 'in' | 'out') {
@@ -60,9 +61,10 @@ export function layoutGraph(nodes: Node[], edges: Edge[], options: LayoutOptions
}) })
for (const node of nodes) { for (const node of nodes) {
const measured = options.dimensions?.[node.id]
graph.setNode(node.id, { graph.setNode(node.id, {
width: DEFAULT_NODE_WIDTH, width: Math.max(DEFAULT_NODE_WIDTH, measured?.width || DEFAULT_NODE_WIDTH),
height: estimateNodeHeight(node), height: Math.max(estimateNodeHeight(node), measured?.height || 0),
}) })
} }
@@ -74,14 +76,16 @@ export function layoutGraph(nodes: Node[], edges: Edge[], options: LayoutOptions
return nodes.map((node) => { return nodes.map((node) => {
const graphNode = graph.node(node.id) const graphNode = graph.node(node.id)
const height = estimateNodeHeight(node) const measured = options.dimensions?.[node.id]
const width = Math.max(DEFAULT_NODE_WIDTH, measured?.width || DEFAULT_NODE_WIDTH)
const height = Math.max(estimateNodeHeight(node), measured?.height || 0)
return { return {
...node, ...node,
targetPosition: targetPosition(direction), targetPosition: targetPosition(direction),
sourcePosition: sourcePosition(direction), sourcePosition: sourcePosition(direction),
position: graphNode position: graphNode
? { x: graphNode.x - DEFAULT_NODE_WIDTH / 2, y: graphNode.y - height / 2 } ? { x: graphNode.x - width / 2, y: graphNode.y - height / 2 }
: node.position, : node.position,
} }
}) })

View File

@@ -665,14 +665,53 @@ button.ghost.danger {
} }
.graphfx-node__image-preview { .graphfx-node__image-preview {
position: relative;
overflow: hidden;
width: 100%; width: 100%;
max-height: 92px; max-height: 92px;
border: 1px solid color-mix(in srgb, var(--io-color) 34%, transparent); border: 1px solid color-mix(in srgb, var(--io-color) 34%, transparent);
border-radius: 10px; border-radius: 10px;
padding: 0;
background: #050913; background: #050913;
cursor: zoom-in;
}
.graphfx-node__image-preview img {
display: block;
width: 100%;
max-height: 92px;
object-fit: contain; object-fit: contain;
} }
.graphfx-node__image-preview span {
position: absolute;
right: 0.35rem;
bottom: 0.3rem;
border-radius: 999px;
padding: 0.12rem 0.38rem;
background: rgb(0 0 0 / 72%);
color: #dff7ff;
font-size: 0.56rem;
font-weight: 850;
opacity: 0;
transition: opacity 120ms ease;
}
.graphfx-node__image-preview:hover span,
.graphfx-node__image-preview:focus-visible span {
opacity: 1;
}
.graphfx-node__image-empty {
border: 1px dashed color-mix(in srgb, var(--io-color) 25%, transparent);
border-radius: 10px;
padding: 0.42rem;
color: color-mix(in srgb, var(--io-color) 50%, #8a99b8 50%);
background: rgb(5 9 19 / 42%);
font-size: 0.64rem;
font-weight: 800;
}
.graphfx-node__label-input, .graphfx-node__label-input,
.graphfx-node__value-input { .graphfx-node__value-input {
width: 100%; width: 100%;
@@ -767,3 +806,70 @@ button.ghost.danger {
.io-type-color { --io-color: #ff78b7; } .io-type-color { --io-color: #ff78b7; }
.io-type-font { --io-color: #fb923c; } .io-type-font { --io-color: #fb923c; }
.io-type-unknown { --io-color: #94a3b8; } .io-type-unknown { --io-color: #94a3b8; }
.image-inspector {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 2rem;
background: rgb(0 0 0 / 72%);
backdrop-filter: blur(8px);
}
.image-inspector__panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 0.85rem;
width: min(92vw, 980px);
max-height: 92vh;
border: 1px solid rgb(157 177 255 / 28%);
border-radius: 20px;
padding: 1rem;
background: #0b1120;
box-shadow: 0 28px 90px rgb(0 0 0 / 55%);
}
.image-inspector__panel header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
}
.image-inspector__panel header div {
display: grid;
gap: 0.2rem;
}
.image-inspector__panel header strong {
color: #fff;
font-size: 1rem;
}
.image-inspector__panel header span {
color: #94a8ca;
font-size: 0.78rem;
}
.image-inspector__panel > img {
max-width: 100%;
max-height: 68vh;
place-self: center;
border-radius: 14px;
background: #050913;
object-fit: contain;
}
.image-inspector__panel textarea {
height: 5.5rem;
resize: vertical;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 12px;
padding: 0.65rem;
background: rgb(5 9 19 / 78%);
color: #8aa0c4;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.68rem;
}