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/controls/dist/style.css'
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 { coerceIoValue, createSerializedNode, nodeCatalog, removeNodeAndConnections } from './lib/editorHelpers'
import { ioTypeFor } from './lib/nodeDefinitions'
@@ -46,6 +46,7 @@ const runtimeStatus = ref('Graph not running')
const runtimeError = ref<string | null>(null)
const selectedOutputKey = ref('')
const outputPreviewUrl = ref<string | null>(null)
const imageInspector = ref<GraphFxImageInspection | null>(null)
let unsubscribeOutput: (() => void) | null = null
let unsubscribeRuntimeOutputPreviews: Array<() => void> = []
@@ -76,6 +77,11 @@ const nodeActions = {
deleteNode,
duplicateNode,
selectInputImage,
inspectImage,
}
function inspectImage(inspection: GraphFxImageInspection) {
imageInspector.value = inspection
}
function loadImageSelections() {
@@ -191,8 +197,33 @@ function refreshFromGraph(options: { fit?: boolean; layout?: boolean } = {}) {
if (options.fit) nextTick(() => fitView({ padding: 0.2, duration: 250 }))
}
function applyAutoLayout() {
refreshFromGraph({ layout: true, fit: true })
function measureNodeDimensions() {
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() {
@@ -717,7 +748,7 @@ refreshFlow()
@edge-double-click="onEdgeDoubleClick"
>
<template #node-graphfx="nodeProps">
<GraphFxNode v-bind="nodeProps" />
<GraphFxNode v-bind="nodeProps" @inspect-image="inspectImage" />
</template>
<Background pattern-color="#2f3a54" :gap="24" />
<Controls />
@@ -750,5 +781,19 @@ refreshFlow()
</button>
</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>
</template>

View File

@@ -8,6 +8,17 @@ const props = defineProps<{
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) {
return `io-type-${port.type.toLowerCase()}`
}
@@ -75,6 +86,19 @@ function inputType(port: GraphFxPort) {
function connectedText(port: GraphFxPort) {
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>
<template>
@@ -135,7 +159,10 @@ function connectedText(port: GraphFxPort) {
<span class="graphfx-node__type-dot" :title="port.type" aria-hidden="true"></span>
</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>
<span :title="port.output"> {{ connectedText(port) }}</span>
@@ -213,7 +240,11 @@ function connectedText(port: GraphFxPort) {
<span class="graphfx-node__port-name">{{ port.name }}</span>
</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
type="source"

View File

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

View File

@@ -10,6 +10,7 @@ type LayoutDirection = 'LR' | 'TB'
type LayoutOptions = {
direction?: LayoutDirection
useSavedPositions?: boolean
dimensions?: Record<string, { width: number; height: number }>
}
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) {
const measured = options.dimensions?.[node.id]
graph.setNode(node.id, {
width: DEFAULT_NODE_WIDTH,
height: estimateNodeHeight(node),
width: Math.max(DEFAULT_NODE_WIDTH, measured?.width || DEFAULT_NODE_WIDTH),
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) => {
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 {
...node,
targetPosition: targetPosition(direction),
sourcePosition: sourcePosition(direction),
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,
}
})

View File

@@ -665,14 +665,53 @@ button.ghost.danger {
}
.graphfx-node__image-preview {
position: relative;
overflow: hidden;
width: 100%;
max-height: 92px;
border: 1px solid color-mix(in srgb, var(--io-color) 34%, transparent);
border-radius: 10px;
padding: 0;
background: #050913;
cursor: zoom-in;
}
.graphfx-node__image-preview img {
display: block;
width: 100%;
max-height: 92px;
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__value-input {
width: 100%;
@@ -767,3 +806,70 @@ button.ghost.danger {
.io-type-color { --io-color: #ff78b7; }
.io-type-font { --io-color: #fb923c; }
.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;
}