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/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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user