mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 00:20:07 +00:00
* cleanup: Extract the logic for outline/border colors * feat: Add coloring for bypass style
445 lines
12 KiB
Vue
445 lines
12 KiB
Vue
<template>
|
|
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
|
{{ $t('Node Render Error') }}
|
|
</div>
|
|
<div
|
|
v-else
|
|
:data-node-id="nodeData.id"
|
|
:class="
|
|
cn(
|
|
'bg-white dark-theme:bg-charcoal-100',
|
|
'lg-node absolute rounded-2xl',
|
|
'border border-solid border-sand-100 dark-theme:border-charcoal-300',
|
|
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
|
|
'outline-transparent -outline-offset-2 outline-2',
|
|
borderClass,
|
|
outlineClass,
|
|
{
|
|
'animate-pulse': executing,
|
|
'opacity-50 before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
|
|
bypassed,
|
|
'will-change-transform': isDragging
|
|
},
|
|
lodCssClass,
|
|
'pointer-events-auto'
|
|
)
|
|
"
|
|
:style="[
|
|
{
|
|
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
|
zIndex: zIndex
|
|
},
|
|
dragStyle
|
|
]"
|
|
@pointerdown="handlePointerDown"
|
|
@pointermove="handlePointerMove"
|
|
@pointerup="handlePointerUp"
|
|
>
|
|
<div class="flex items-center">
|
|
<template v-if="isCollapsed">
|
|
<SlotConnectionDot multi class="absolute left-0 -translate-x-1/2" />
|
|
<SlotConnectionDot multi class="absolute right-0 translate-x-1/2" />
|
|
</template>
|
|
<!-- Header only updates on title/color changes -->
|
|
<NodeHeader
|
|
v-memo="[nodeData.title, lodLevel, isCollapsed]"
|
|
:node-data="nodeData"
|
|
:readonly="readonly"
|
|
:lod-level="lodLevel"
|
|
:collapsed="isCollapsed"
|
|
@collapse="handleCollapse"
|
|
@update:title="handleTitleUpdate"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="
|
|
(isMinimalLOD || isCollapsed) && executing && progress !== undefined
|
|
"
|
|
:class="
|
|
cn(
|
|
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
|
|
progressClasses
|
|
)
|
|
"
|
|
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
|
/>
|
|
|
|
<template v-if="!isMinimalLOD && !isCollapsed">
|
|
<div class="mb-4 relative">
|
|
<div :class="separatorClasses" />
|
|
<!-- Progress bar for executing state -->
|
|
<div
|
|
v-if="executing && progress !== undefined"
|
|
:class="
|
|
cn(
|
|
'absolute inset-x-0 top-1/2 -translate-y-1/2',
|
|
!!(progress < 1) && 'rounded-r-full',
|
|
progressClasses
|
|
)
|
|
"
|
|
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
|
<div
|
|
class="flex flex-col gap-4 pb-4"
|
|
:data-testid="`node-body-${nodeData.id}`"
|
|
>
|
|
<!-- Slots only rendered at full detail -->
|
|
<NodeSlots
|
|
v-if="shouldRenderSlots"
|
|
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length, lodLevel]"
|
|
:node-data="nodeData"
|
|
:readonly="readonly"
|
|
:lod-level="lodLevel"
|
|
@slot-click="handleSlotClick"
|
|
/>
|
|
|
|
<!-- Widgets rendered at reduced+ detail -->
|
|
<NodeWidgets
|
|
v-if="shouldShowWidgets"
|
|
v-memo="[nodeData.widgets?.length, lodLevel]"
|
|
:node-data="nodeData"
|
|
:readonly="readonly"
|
|
:lod-level="lodLevel"
|
|
/>
|
|
|
|
<!-- Custom content at reduced+ detail -->
|
|
<NodeContent
|
|
v-if="shouldShowContent"
|
|
:node-data="nodeData"
|
|
:readonly="readonly"
|
|
:lod-level="lodLevel"
|
|
:image-urls="nodeImageUrls"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {
|
|
computed,
|
|
inject,
|
|
onErrorCaptured,
|
|
onMounted,
|
|
provide,
|
|
ref,
|
|
toRef,
|
|
watch
|
|
} from 'vue'
|
|
|
|
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
|
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
|
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
|
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
|
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
|
import { ExecutedWsMessage } from '@/schemas/apiSchema'
|
|
import { app } from '@/scripts/app'
|
|
import { useExecutionStore } from '@/stores/executionStore'
|
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
|
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
|
|
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
|
|
import NodeContent from './NodeContent.vue'
|
|
import NodeHeader from './NodeHeader.vue'
|
|
import NodeSlots from './NodeSlots.vue'
|
|
import NodeWidgets from './NodeWidgets.vue'
|
|
import SlotConnectionDot from './SlotConnectionDot.vue'
|
|
|
|
// Extended props for main node component
|
|
interface LGraphNodeProps {
|
|
nodeData: VueNodeData
|
|
position?: { x: number; y: number }
|
|
size?: { width: number; height: number }
|
|
readonly?: boolean
|
|
error?: string | null
|
|
zoomLevel?: number
|
|
}
|
|
|
|
const {
|
|
nodeData,
|
|
position,
|
|
size,
|
|
error = null,
|
|
readonly = false,
|
|
zoomLevel = 1
|
|
} = defineProps<LGraphNodeProps>()
|
|
|
|
const emit = defineEmits<{
|
|
'node-click': [
|
|
event: PointerEvent,
|
|
nodeData: VueNodeData,
|
|
wasDragging: boolean
|
|
]
|
|
'slot-click': [
|
|
event: PointerEvent,
|
|
nodeData: VueNodeData,
|
|
slotIndex: number,
|
|
isInput: boolean
|
|
]
|
|
dragStart: [event: DragEvent, nodeData: VueNodeData]
|
|
'update:collapsed': [nodeId: string, collapsed: boolean]
|
|
'update:title': [nodeId: string, newTitle: string]
|
|
}>()
|
|
|
|
useVueElementTracking(nodeData.id, 'node')
|
|
|
|
// Inject selection state from parent
|
|
const selectedNodeIds = inject(SelectedNodeIdsKey)
|
|
if (!selectedNodeIds) {
|
|
throw new Error(
|
|
'SelectedNodeIds not provided - LGraphNode must be used within a component that provides selection state'
|
|
)
|
|
}
|
|
|
|
// Inject transform state for coordinate conversion
|
|
const transformState = inject('transformState') as
|
|
| {
|
|
camera: { z: number }
|
|
canvasToScreen: (point: { x: number; y: number }) => {
|
|
x: number
|
|
y: number
|
|
}
|
|
screenToCanvas: (point: { x: number; y: number }) => {
|
|
x: number
|
|
y: number
|
|
}
|
|
}
|
|
| undefined
|
|
|
|
// Computed selection state - only this node re-evaluates when its selection changes
|
|
const isSelected = computed(() => {
|
|
return selectedNodeIds.value.has(nodeData.id)
|
|
})
|
|
|
|
// Use execution state composable
|
|
const { executing, progress } = useNodeExecutionState(nodeData.id)
|
|
|
|
// Direct access to execution store for error state
|
|
const executionStore = useExecutionStore()
|
|
const hasExecutionError = computed(
|
|
() => executionStore.lastExecutionErrorNodeId === nodeData.id
|
|
)
|
|
|
|
// Computed error states for styling
|
|
const hasAnyError = computed(
|
|
(): boolean => !!(hasExecutionError.value || nodeData.hasErrors || error)
|
|
)
|
|
|
|
const bypassed = computed((): boolean => nodeData.mode === 4)
|
|
|
|
// LOD (Level of Detail) system based on zoom level
|
|
const zoomRef = toRef(() => zoomLevel)
|
|
const {
|
|
lodLevel,
|
|
shouldRenderWidgets,
|
|
shouldRenderSlots,
|
|
shouldRenderContent,
|
|
lodCssClass
|
|
} = useLOD(zoomRef)
|
|
|
|
// Computed properties for template usage
|
|
const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL)
|
|
|
|
// Error boundary implementation
|
|
const renderError = ref<string | null>(null)
|
|
const { toastErrorHandler } = useErrorHandling()
|
|
|
|
onErrorCaptured((error) => {
|
|
renderError.value = error.message
|
|
toastErrorHandler(error)
|
|
return false // Prevent error propagation
|
|
})
|
|
|
|
// Use layout system for node position and dragging
|
|
const {
|
|
position: layoutPosition,
|
|
zIndex,
|
|
startDrag,
|
|
handleDrag: handleLayoutDrag,
|
|
endDrag,
|
|
resize
|
|
} = useNodeLayout(nodeData.id)
|
|
|
|
onMounted(() => {
|
|
if (size && transformState) {
|
|
const scale = transformState.camera.z
|
|
const screenSize = {
|
|
width: size.width * scale,
|
|
height: size.height * scale
|
|
}
|
|
resize(screenSize)
|
|
}
|
|
})
|
|
|
|
// Drag state for styling
|
|
const isDragging = ref(false)
|
|
const dragStyle = computed(() => ({
|
|
cursor: isDragging.value ? 'grabbing' : 'grab'
|
|
}))
|
|
const lastY = ref(0)
|
|
const lastX = ref(0)
|
|
// Treat tiny pointer jitter as a click, not a drag
|
|
const DRAG_THRESHOLD_PX = 4
|
|
|
|
// Track collapsed state
|
|
const isCollapsed = ref(nodeData.flags?.collapsed ?? false)
|
|
|
|
// Watch for external changes to the collapsed state
|
|
watch(
|
|
() => nodeData.flags?.collapsed,
|
|
(newCollapsed: boolean | undefined) => {
|
|
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
|
|
isCollapsed.value = newCollapsed
|
|
}
|
|
}
|
|
)
|
|
|
|
// Check if node has custom content (like image outputs)
|
|
const hasCustomContent = computed(() => {
|
|
// Show custom content if node has image outputs
|
|
return nodeImageUrls.value.length > 0
|
|
})
|
|
|
|
// Computed classes and conditions for better reusability
|
|
const separatorClasses =
|
|
'bg-sand-100 dark-theme:bg-charcoal-300 h-[1px] mx-0 w-full'
|
|
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
|
|
|
|
// Common condition computations to avoid repetition
|
|
const shouldShowWidgets = computed(
|
|
() => shouldRenderWidgets.value && nodeData.widgets?.length
|
|
)
|
|
|
|
const shouldShowContent = computed(
|
|
() => shouldRenderContent.value && hasCustomContent.value
|
|
)
|
|
|
|
const borderClass = computed(() => {
|
|
if (hasAnyError.value) {
|
|
return 'border-error'
|
|
}
|
|
if (executing.value) {
|
|
return 'border-blue-500'
|
|
}
|
|
return undefined
|
|
})
|
|
|
|
const outlineClass = computed(() => {
|
|
if (!isSelected.value) {
|
|
return undefined
|
|
}
|
|
if (hasAnyError.value) {
|
|
return 'outline-error'
|
|
}
|
|
if (executing.value) {
|
|
return 'outline-blue-500'
|
|
}
|
|
return 'outline-black dark-theme:outline-white'
|
|
})
|
|
|
|
// Event handlers
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
if (!nodeData) {
|
|
console.warn('LGraphNode: nodeData is null/undefined in handlePointerDown')
|
|
return
|
|
}
|
|
|
|
// Start drag using layout system
|
|
isDragging.value = true
|
|
startDrag(event)
|
|
lastY.value = event.clientY
|
|
lastX.value = event.clientX
|
|
}
|
|
|
|
const handlePointerMove = (event: PointerEvent) => {
|
|
if (isDragging.value) {
|
|
void handleLayoutDrag(event)
|
|
}
|
|
}
|
|
|
|
const handlePointerUp = (event: PointerEvent) => {
|
|
if (isDragging.value) {
|
|
isDragging.value = false
|
|
void endDrag(event)
|
|
}
|
|
// Emit node-click for selection handling in GraphCanvas
|
|
const dx = event.clientX - lastX.value
|
|
const dy = event.clientY - lastY.value
|
|
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
|
|
emit('node-click', event, nodeData, wasDragging)
|
|
}
|
|
|
|
const handleCollapse = () => {
|
|
isCollapsed.value = !isCollapsed.value
|
|
// Emit event so parent can sync with LiteGraph if needed
|
|
emit('update:collapsed', nodeData.id, isCollapsed.value)
|
|
}
|
|
|
|
const handleSlotClick = (
|
|
event: PointerEvent,
|
|
slotIndex: number,
|
|
isInput: boolean
|
|
) => {
|
|
if (!nodeData) {
|
|
console.warn('LGraphNode: nodeData is null/undefined in handleSlotClick')
|
|
return
|
|
}
|
|
emit('slot-click', event, nodeData, slotIndex, isInput)
|
|
}
|
|
|
|
const handleTitleUpdate = (newTitle: string) => {
|
|
emit('update:title', nodeData.id, newTitle)
|
|
}
|
|
|
|
const nodeOutputs = useNodeOutputStore()
|
|
|
|
const nodeImageUrls = ref<string[]>([])
|
|
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
|
|
// Construct proper locator ID using subgraph ID from VueNodeData
|
|
const locatorId = nodeData.subgraphId
|
|
? `${nodeData.subgraphId}:${nodeData.id}`
|
|
: nodeData.id
|
|
|
|
// Use root graph for getNodeByLocatorId since it needs to traverse from root
|
|
const rootGraph = app.graph?.rootGraph || app.graph
|
|
if (!rootGraph) {
|
|
nodeImageUrls.value = []
|
|
return
|
|
}
|
|
|
|
const node = getNodeByLocatorId(rootGraph, locatorId)
|
|
|
|
if (node && newOutputs?.images?.length) {
|
|
const urls = nodeOutputs.getNodeImageUrls(node)
|
|
if (urls) {
|
|
nodeImageUrls.value = urls
|
|
}
|
|
} else {
|
|
// Clear URLs if no outputs or no images
|
|
nodeImageUrls.value = []
|
|
}
|
|
}
|
|
|
|
const nodeOutputLocatorId = computed(() =>
|
|
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
|
|
)
|
|
|
|
watch(
|
|
() => nodeOutputs.nodeOutputs[nodeOutputLocatorId.value],
|
|
(newOutputs) => {
|
|
onNodeOutputsUpdate(newOutputs)
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
// Provide nodeImageUrls to child components
|
|
provide('nodeImageUrls', nodeImageUrls)
|
|
</script>
|