[feat] Implement Vue-based node rendering components

- LGraphNode: Main container with transform-based positioning
- NodeHeader: Collapsible title bar with dynamic coloring
- NodeSlots: Input/output connection visualization
- NodeWidgets: Integration with existing widget system
- NodeContent: Extensibility placeholder
- Error boundaries and performance optimizations (v-memo, CSS containment)
This commit is contained in:
bymyself
2025-06-24 18:25:08 -07:00
parent 0ec98e3b99
commit a041f40fb5
7 changed files with 630 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--input flex items-center gap-2 py-1 pl-2 pr-4 cursor-crosshair hover:bg-black/5"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible
}"
@pointerdown="handleClick"
>
<!-- Connection Dot -->
<div
class="lg-slot__dot w-3 h-3 rounded-full border-2"
:style="{
backgroundColor: connected ? slotColor : 'transparent',
borderColor: slotColor
}"
/>
<!-- Slot Name -->
<span class="text-xs text-surface-700 whitespace-nowrap">
{{ slotData.name || `Input ${index}` }}
</span>
</div>
</template>
<script setup lang="ts">
import type { INodeSlot, LGraphNode } from '@comfyorg/litegraph'
import { computed, onErrorCaptured, ref } from 'vue'
import { getSlotColor } from '@/constants/slotColors'
interface InputSlotProps {
node: LGraphNode
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
}
const props = defineProps<InputSlotProps>()
const emit = defineEmits<{
'slot-click': [event: PointerEvent]
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue input slot error:', error)
return false
})
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Handle click events
const handleClick = (event: PointerEvent) => {
if (!props.readonly) {
emit('slot-click', event)
}
}
</script>

View File

@@ -0,0 +1,168 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Render Error
</div>
<div
v-else
:class="[
'lg-node absolute border-2 rounded bg-surface-0',
'contain-layout contain-style contain-paint',
selected
? 'border-primary-500 ring-2 ring-primary-300'
: 'border-surface-300',
executing ? 'animate-pulse' : '',
node.mode === 4 ? 'opacity-50' : '', // bypassed
error ? 'border-red-500 bg-red-50' : '',
isDragging ? 'will-change-transform' : ''
]"
:style="{
transform: `translate(${position.x}px, ${position.y}px)`,
width: node.size ? `${node.size[0]}px` : 'auto',
minWidth: '200px'
}"
@pointerdown="handlePointerDown"
>
<!-- Header only updates on title/color changes -->
<NodeHeader
v-memo="[node.title, node.color]"
:node="node"
:readonly="readonly"
@collapse="handleCollapse"
/>
<!-- Node Body (only visible when not collapsed) -->
<div v-if="!node.flags?.collapsed" class="flex flex-col gap-2 p-2">
<!-- Slots only update when connections change -->
<NodeSlots
v-memo="[node.inputs?.length, node.outputs?.length]"
:node="node"
:readonly="readonly"
@slot-click="handleSlotClick"
/>
<!-- Widgets update on value changes -->
<NodeWidgets
v-if="node.widgets?.length"
v-memo="[
node.widgets?.length,
...(node.widgets?.map((w) => w.value) ?? [])
]"
:node="node"
:readonly="readonly"
/>
<!-- Custom content area -->
<NodeContent v-if="hasCustomContent" :node="node" :readonly="readonly" />
</div>
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
class="absolute bottom-0 left-0 h-1 bg-primary-500 transition-all duration-300"
:style="{ width: `${progress * 100}%` }"
/>
</div>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { computed, onErrorCaptured, reactive, ref, watch } from 'vue'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
import NodeWidgets from './NodeWidgets.vue'
// Extended props for main node component
interface LGraphNodeProps {
node: LGraphNode
readonly?: boolean
selected?: boolean
executing?: boolean
progress?: number
error?: string | null
zoomLevel?: number
}
const props = defineProps<LGraphNodeProps>()
const emit = defineEmits<{
'node-click': [event: PointerEvent, node: LGraphNode]
'slot-click': [
event: PointerEvent,
node: LGraphNode,
slotIndex: number,
isInput: boolean
]
collapse: []
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue node component error:', error)
return false // Prevent error propagation
})
// Position state - initialized from node.pos but then controlled via transforms
const position = reactive({
x: props.node.pos[0],
y: props.node.pos[1]
})
// Track dragging state for will-change optimization
const isDragging = ref(false)
// Only update position when node.pos changes AND we're not dragging
// This prevents reflows during drag operations
watch(
() => props.node.pos,
(newPos) => {
if (!isDragging.value) {
position.x = newPos[0]
position.y = newPos[1]
}
},
{ deep: true }
)
// Check if node has custom content
const hasCustomContent = computed(() => {
// Currently all content is handled through widgets
// This remains false but provides extensibility point
return false
})
// Event handlers
const handlePointerDown = (event: PointerEvent) => {
emit('node-click', event, props.node)
// The parent component will handle setting isDragging when appropriate
}
const handleCollapse = () => {
// Parent component should handle node mutations
// This is just emitting the event upwards
emit('collapse')
}
const handleSlotClick = (
event: PointerEvent,
slotIndex: number,
isInput: boolean
) => {
emit('slot-click', event, props.node, slotIndex, isInput)
}
// Expose methods for parent to control position during drag
defineExpose({
setPosition(x: number, y: number) {
position.x = x
position.y = y
},
setDragging(dragging: boolean) {
isDragging.value = dragging
}
})
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Content Error
</div>
<div v-else class="lg-node-content">
<!-- Default slot for custom content -->
<slot>
<!-- This component serves as a placeholder for future extensibility -->
<!-- Currently all node content is rendered through the widget system -->
</slot>
</div>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { onErrorCaptured, ref } from 'vue'
interface NodeContentProps {
node: LGraphNode
readonly?: boolean
}
defineProps<NodeContentProps>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue node content error:', error)
return false
})
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Header Error
</div>
<div
v-else
class="lg-node-header flex items-center justify-between px-3 py-2 rounded-t cursor-move"
:style="{
backgroundColor: headerColor,
color: textColor
}"
@dblclick="handleDoubleClick"
>
<!-- Node Title -->
<span class="text-sm font-medium truncate flex-1">
{{ node.title || node.constructor.title || 'Untitled' }}
</span>
<!-- Node Controls -->
<div class="flex items-center gap-1 ml-2">
<!-- Collapse/Expand Button -->
<button
v-if="!readonly"
class="lg-node-header__control p-0.5 rounded hover:bg-black/10 transition-colors"
:title="node.flags?.collapsed ? 'Expand' : 'Collapse'"
@click.stop="handleCollapse"
>
<svg
class="w-3 h-3 transition-transform"
:class="{ 'rotate-180': node.flags?.collapsed }"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<!-- Additional controls can be added here -->
</div>
</div>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { computed, onErrorCaptured, ref } from 'vue'
interface NodeHeaderProps {
node: LGraphNode
readonly?: boolean
}
const props = defineProps<NodeHeaderProps>()
const emit = defineEmits<{
collapse: []
'title-edit': []
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue node header error:', error)
return false
})
// Compute header color based on node color property or type
const headerColor = computed(() => {
if (props.node.color) {
return props.node.color
}
// Default color based on node mode
if (props.node.mode === 4) return '#666' // Bypassed
if (props.node.mode === 2) return '#444' // Muted
return '#353535' // Default
})
// Compute text color for contrast
const textColor = computed(() => {
// Simple contrast calculation - could be improved
const color = headerColor.value
if (!color || color === '#353535' || color === '#444' || color === '#666') {
return '#fff'
}
// For custom colors, use a simple heuristic
const rgb = parseInt(color.slice(1), 16)
const r = (rgb >> 16) & 255
const g = (rgb >> 8) & 255
const b = rgb & 255
const brightness = (r * 299 + g * 587 + b * 114) / 1000
return brightness > 128 ? '#000' : '#fff'
})
// Event handlers
const handleCollapse = () => {
emit('collapse')
}
const handleDoubleClick = () => {
if (!props.readonly) {
emit('title-edit')
}
}
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Slots Error
</div>
<div v-else class="lg-node-slots relative">
<!-- Input Slots -->
<div
v-if="node.inputs?.length"
class="lg-node-slots__inputs absolute left-0 top-0 flex flex-col"
>
<InputSlot
v-for="(input, index) in node.inputs"
:key="`input-${index}`"
:node="node"
:slot-data="input"
:index="index"
:connected="isInputConnected(index)"
:compatible="false"
:readonly="readonly"
@slot-click="(e) => handleSlotClick(e, index, true)"
/>
</div>
<!-- Output Slots -->
<div
v-if="node.outputs?.length"
class="lg-node-slots__outputs absolute right-0 top-0 flex flex-col"
>
<OutputSlot
v-for="(output, index) in node.outputs"
:key="`output-${index}`"
:node="node"
:slot-data="output"
:index="index"
:connected="isOutputConnected(index)"
:compatible="false"
:readonly="readonly"
@slot-click="(e) => handleSlotClick(e, index, false)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { onErrorCaptured, ref } from 'vue'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
interface NodeSlotsProps {
node: LGraphNode
readonly?: boolean
}
const props = defineProps<NodeSlotsProps>()
const emit = defineEmits<{
'slot-click': [event: PointerEvent, slotIndex: number, isInput: boolean]
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue node slots error:', error)
return false
})
// Check if input slot has a connection
const isInputConnected = (index: number) => {
return props.node.inputs?.[index]?.link != null
}
// Check if output slot has any connections
const isOutputConnected = (index: number) => {
return (props.node.outputs?.[index]?.links?.length ?? 0) > 0
}
// Handle slot click events
const handleSlotClick = (
event: PointerEvent,
slotIndex: number,
isInput: boolean
) => {
emit('slot-click', event, slotIndex, isInput)
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Widgets Error
</div>
<div v-else class="lg-node-widgets flex flex-col gap-2">
<component
:is="getWidgetComponent(widget)"
v-for="(widget, index) in widgets"
:key="`widget-${index}-${widget.name}`"
v-model="widget.value"
:widget="simplifiedWidget(widget)"
:readonly="readonly"
@update:model-value="(value: any) => handleWidgetUpdate(widget, value)"
/>
</div>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { computed, onErrorCaptured, ref } from 'vue'
import {
WidgetType,
getWidgetComponent as getWidgetComponentFromRegistry
} from '@/components/graph/vueWidgets/widgetRegistry'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
interface NodeWidgetsProps {
node: LGraphNode
readonly?: boolean
}
const props = defineProps<NodeWidgetsProps>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue node widgets error:', error)
return false
})
// Get non-hidden widgets
const widgets = computed(() => {
return props.node.widgets?.filter((w) => !w.options?.hidden) || []
})
// Map widget type to our widget registry
const getWidgetComponent = (widget: IBaseWidget) => {
// Map LiteGraph widget types to our WidgetType enum
const typeMapping: Record<string, WidgetType> = {
number: WidgetType.SLIDER,
float: WidgetType.SLIDER,
int: WidgetType.SLIDER,
string: WidgetType.STRING,
text: WidgetType.TEXTAREA,
combo: WidgetType.COMBO,
toggle: WidgetType.BOOLEAN,
boolean: WidgetType.BOOLEAN,
button: WidgetType.BUTTON,
color: WidgetType.COLOR,
image: WidgetType.IMAGE,
file: WidgetType.FILEUPLOAD
}
const widgetType = typeMapping[widget.type] || widget.type.toUpperCase()
return getWidgetComponentFromRegistry(widgetType) || null
}
// Convert LiteGraph widget to SimplifiedWidget interface
const simplifiedWidget = (widget: IBaseWidget): SimplifiedWidget => {
return {
name: widget.name,
type: widget.type,
value: widget.value,
options: widget.options,
callback: widget.callback
}
}
// Handle widget value updates
const handleWidgetUpdate = (widget: IBaseWidget, value: any) => {
widget.value = value
// Call widget callback if exists
if (widget.callback) {
widget.callback(value)
}
// Mark node as dirty for LiteGraph
if (props.node.onWidgetChanged) {
props.node.onWidgetChanged(widget.name, value, null, widget)
}
}
</script>

View File

@@ -0,0 +1,68 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--output flex items-center gap-2 py-1 pr-2 pl-4 cursor-crosshair hover:bg-black/5 justify-end"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible
}"
@pointerdown="handleClick"
>
<!-- Slot Name -->
<span class="text-xs text-surface-700 whitespace-nowrap">
{{ slotData.name || `Output ${index}` }}
</span>
<!-- Connection Dot -->
<div
class="lg-slot__dot w-3 h-3 rounded-full border-2"
:style="{
backgroundColor: connected ? slotColor : 'transparent',
borderColor: slotColor
}"
/>
</div>
</template>
<script setup lang="ts">
import type { INodeSlot, LGraphNode } from '@comfyorg/litegraph'
import { computed, onErrorCaptured, ref } from 'vue'
import { getSlotColor } from '@/constants/slotColors'
interface OutputSlotProps {
node: LGraphNode
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
}
const props = defineProps<OutputSlotProps>()
const emit = defineEmits<{
'slot-click': [event: PointerEvent]
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue output slot error:', error)
return false
})
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Handle click events
const handleClick = (event: PointerEvent) => {
if (!props.readonly) {
emit('slot-click', event)
}
}
</script>