Add image IO inspector and measured layout
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user