260 lines
9.5 KiB
Vue
260 lines
9.5 KiB
Vue
<script setup lang="ts">
|
||
import { Handle, Position } from '@vue-flow/core'
|
||
import type { GraphFxNodeData, GraphFxPort } from '../lib/graphfxToVueFlow'
|
||
import { getInputHandleId, getOutputHandleId } from '../lib/graphfxToVueFlow'
|
||
import { displayIoValue } from '../lib/editorHelpers'
|
||
|
||
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()}`
|
||
}
|
||
|
||
function hasCustomLabel(port: GraphFxPort) {
|
||
return Boolean(port.label && port.label !== port.name)
|
||
}
|
||
|
||
function labelValue(port: GraphFxPort) {
|
||
return hasCustomLabel(port) ? port.label : ''
|
||
}
|
||
|
||
function previewUrl(direction: 'in' | 'out', port: GraphFxPort) {
|
||
return props.data.imagePreviews?.[`${direction}:${port.name}`] || port.previewUrl || ''
|
||
}
|
||
|
||
function hasSelectOptions(port: GraphFxPort) {
|
||
return Boolean(port.options?.length)
|
||
}
|
||
|
||
function updateNodeId(event: Event) {
|
||
const target = event.target as HTMLInputElement
|
||
props.data.actions?.renameNodeId(props.data.id, target.value)
|
||
}
|
||
|
||
function updateLabel(direction: 'in' | 'out', port: GraphFxPort, event: Event) {
|
||
const target = event.target as HTMLInputElement
|
||
props.data.actions?.updateIoLabel(props.data.id, direction, port.name, target.value)
|
||
}
|
||
|
||
function updateInput(port: GraphFxPort, event: Event) {
|
||
const target = event.target as HTMLInputElement | HTMLSelectElement
|
||
const value = port.type === 'Boolean' ? (target as HTMLInputElement).checked : target.value
|
||
props.data.actions?.updateInputValue(props.data.id, port.name, value)
|
||
}
|
||
|
||
function selectImage(port: GraphFxPort, event: Event) {
|
||
const target = event.target as HTMLSelectElement
|
||
if (!target.value) return
|
||
props.data.actions?.selectInputImage(props.data.id, port.name, target.value)
|
||
}
|
||
|
||
function selectedImage(port: GraphFxPort) {
|
||
return props.data.selectedImages?.[port.name] || ''
|
||
}
|
||
|
||
function deleteNode() {
|
||
props.data.actions?.deleteNode(props.data.id)
|
||
}
|
||
|
||
function duplicateNode() {
|
||
props.data.actions?.duplicateNode(props.data.id)
|
||
}
|
||
|
||
function disconnectInput(port: GraphFxPort) {
|
||
props.data.actions?.disconnectInput(props.data.id, port.name)
|
||
}
|
||
|
||
function inputType(port: GraphFxPort) {
|
||
if (port.type === 'Number') return 'number'
|
||
if (port.type === 'Color') return 'color'
|
||
return 'text'
|
||
}
|
||
|
||
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>
|
||
<article class="graphfx-node">
|
||
<header class="graphfx-node__header">
|
||
<div>
|
||
<h3>{{ data.name }}</h3>
|
||
<input
|
||
class="graphfx-node__id nodrag"
|
||
:value="data.id"
|
||
aria-label="Node id"
|
||
title="Node id"
|
||
@change="updateNodeId"
|
||
@pointerdown.stop
|
||
@keydown.stop
|
||
/>
|
||
</div>
|
||
|
||
<details class="graphfx-node__menu nodrag" @pointerdown.stop @click.stop>
|
||
<summary title="Node actions">⋯</summary>
|
||
<div class="graphfx-node__menu-popover">
|
||
<button type="button" class="secondary" @click="duplicateNode">Duplicate</button>
|
||
<button type="button" class="danger" @click="deleteNode">Delete</button>
|
||
</div>
|
||
</details>
|
||
</header>
|
||
|
||
<section class="graphfx-node__ports" :class="{ 'graphfx-node__ports--empty': !data.inputs.length && !data.outputs.length }">
|
||
<div class="graphfx-node__column graphfx-node__column--inputs">
|
||
<div v-if="!data.inputs.length" class="graphfx-node__empty">no inputs</div>
|
||
<div
|
||
v-for="port in data.inputs"
|
||
:key="port.name"
|
||
class="graphfx-node__port graphfx-node__port--input"
|
||
:class="[typeClass(port), { 'graphfx-node__port--connected': port.output, 'graphfx-node__port--labeled': hasCustomLabel(port) }]"
|
||
:title="`${port.name} · ${port.type}${port.output ? ` · ${port.output}` : ''}`"
|
||
>
|
||
<Handle
|
||
type="target"
|
||
:id="getInputHandleId(data.id, port.name)"
|
||
:position="Position.Left"
|
||
class="graphfx-node__handle graphfx-node__handle--input"
|
||
/>
|
||
|
||
<div class="graphfx-node__port-head">
|
||
<span class="graphfx-node__port-name">{{ port.name }}</span>
|
||
<span class="graphfx-node__label-slot nodrag" @pointerdown.stop>
|
||
<span v-if="hasCustomLabel(port)" class="graphfx-node__label-chip">{{ port.label }}</span>
|
||
<input
|
||
class="graphfx-node__label-inline"
|
||
:value="labelValue(port)"
|
||
placeholder="label"
|
||
title="Input label"
|
||
@change="updateLabel('in', port, $event)"
|
||
@keydown.stop
|
||
/>
|
||
</span>
|
||
<span class="graphfx-node__type-dot" :title="port.type" aria-hidden="true"></span>
|
||
</div>
|
||
|
||
<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>
|
||
<button type="button" class="ghost danger" title="Disconnect input" @click="disconnectInput(port)">×</button>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<select
|
||
v-if="port.type === 'Image'"
|
||
class="graphfx-node__value-input nodrag"
|
||
:value="selectedImage(port)"
|
||
:disabled="!data.imageOptions?.length"
|
||
title="Use uploaded image as this input"
|
||
@change="selectImage(port, $event)"
|
||
@pointerdown.stop
|
||
@keydown.stop
|
||
>
|
||
<option value="">{{ data.imageOptions?.length ? 'choose uploaded image' : 'upload images first' }}</option>
|
||
<option v-for="image in data.imageOptions" :key="image.id" :value="image.id">{{ image.name }}</option>
|
||
</select>
|
||
|
||
<select
|
||
v-else-if="hasSelectOptions(port)"
|
||
class="graphfx-node__value-input nodrag"
|
||
:value="displayIoValue(port.value)"
|
||
title="Input value"
|
||
@change="updateInput(port, $event)"
|
||
@pointerdown.stop
|
||
@keydown.stop
|
||
>
|
||
<option v-for="option in port.options" :key="option" :value="option">{{ option }}</option>
|
||
</select>
|
||
|
||
<label v-else-if="port.type === 'Boolean'" class="graphfx-node__checkbox nodrag" @pointerdown.stop>
|
||
<input :checked="Boolean(port.value)" type="checkbox" @change="updateInput(port, $event)" />
|
||
{{ Boolean(port.value) ? 'true' : 'false' }}
|
||
</label>
|
||
|
||
<input
|
||
v-else
|
||
class="graphfx-node__value-input nodrag"
|
||
:type="inputType(port)"
|
||
:value="displayIoValue(port.value)"
|
||
title="Input value"
|
||
@change="updateInput(port, $event)"
|
||
@pointerdown.stop
|
||
@keydown.stop
|
||
/>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="graphfx-node__column graphfx-node__column--outputs">
|
||
<div v-if="!data.outputs.length" class="graphfx-node__empty">no outputs</div>
|
||
<div
|
||
v-for="port in data.outputs"
|
||
:key="port.name"
|
||
class="graphfx-node__port graphfx-node__port--output"
|
||
:class="[typeClass(port), { 'graphfx-node__port--labeled': hasCustomLabel(port) }]"
|
||
:title="`${port.name} · ${port.type}`"
|
||
>
|
||
<div class="graphfx-node__port-head graphfx-node__port-head--right">
|
||
<span class="graphfx-node__type-dot" :title="port.type" aria-hidden="true"></span>
|
||
<span class="graphfx-node__label-slot nodrag" @pointerdown.stop>
|
||
<span v-if="hasCustomLabel(port)" class="graphfx-node__label-chip">{{ port.label }}</span>
|
||
<input
|
||
class="graphfx-node__label-inline"
|
||
:value="labelValue(port)"
|
||
placeholder="label"
|
||
title="Output label"
|
||
@change="updateLabel('out', port, $event)"
|
||
@keydown.stop
|
||
/>
|
||
</span>
|
||
<span class="graphfx-node__port-name">{{ port.name }}</span>
|
||
</div>
|
||
|
||
<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"
|
||
:id="getOutputHandleId(data.id, port.name)"
|
||
:position="Position.Right"
|
||
class="graphfx-node__handle graphfx-node__handle--output"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</article>
|
||
</template>
|