Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/components/LGraphNode.vue
Terry Jia 78635294ce feat: add hideOutputImages flag for nodes with custom preview (#8857)
## Summary

Prerequisite for upcoming native color correction nodes (ColorCorrect,
ColorBalance, ColorCurves) which render their own WebGL preview and need
to suppress the default output image display while keeping data in the
store for downstream nodes.

## Screenshots (if applicable)
before
<img width="980" height="1580" alt="image"
src="https://github.com/user-attachments/assets/2e08869f-1cad-4637-8174-96d034da524c"
/>

after
<img width="783" height="1276" alt="image"
src="https://github.com/user-attachments/assets/3f9b50ee-268c-48f4-9e63-89ef8d732157"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8857-feat-add-hideOutputImages-flag-for-nodes-with-custom-preview-3076d73d365081a2aa55d34280601b47)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-14 03:55:36 -05:00

707 lines
22 KiB
Vue

<template>
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ st('nodeErrors.render', 'Node Render Error') }}
</div>
<div
v-else
ref="nodeContainerRef"
tabindex="0"
:data-node-id="nodeData.id"
:class="
cn(
'group/node bg-node-component-header-surface lg-node absolute text-sm',
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
shapeClass,
'touch-none flex flex-col',
'border-1 border-solid border-component-node-border',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-node-component-ring',
'outline-transparent outline-3 focus-visible:outline-node-component-outline',
borderClass,
outlineClass,
cursorClass,
{
[`${beforeShapeClass} before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0`]:
bypassed,
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
muted,
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
},
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
? 'pointer-events-auto'
: 'pointer-events-none'
)
"
:style="[
{
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex,
opacity: nodeOpacity,
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
backgroundColor: applyLightThemeColor(nodeData?.color)
}
]"
v-bind="remainingPointerHandlers"
@pointerdown="nodeOnPointerdown"
@wheel="handleWheel"
@contextmenu="handleContextMenu"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
>
<div
v-if="displayHeader"
class="flex flex-col justify-center items-center relative"
>
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
multi
class="absolute left-0 -translate-x-1/2"
/>
<SlotConnectionDot
v-if="hasOutputs"
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
</template>
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
/>
</div>
<div
v-if="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="!isCollapsed">
<div class="relative">
<!-- 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>
<div
class="flex flex-1 flex-col gap-1 pt-1 pb-3 bg-component-node-background rounded-b-2xl"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex">
<NodeContent :node-data="nodeData" :media="nodeMedia" />
</div>
<!-- Live mid-execution preview images -->
<LivePreview
v-if="shouldShowPreviewImg"
:image-url="latestPreviewUrl"
/>
<NodeBadges v-bind="badges" :pricing="undefined" />
</div>
</template>
<Button
variant="textonly"
:class="
cn(
'w-full h-7 rounded-b-2xl py-2 -z-1 text-xs rounded-t-none',
hasAnyError && 'hover:bg-destructive-background-hover',
!isCollapsed && '-mt-5 pt-7 h-12'
)
"
as-child
>
<button
v-if="hasAnyError"
@click.stop="useRightSidePanelStore().openPanel('error')"
>
<span>{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4" />
</button>
<button
v-else-if="lgraphNode?.isSubgraphNode()"
data-testid="subgraph-enter-button"
@click.stop="handleEnterSubgraph"
>
<span>{{ t('g.enterSubgraph') }}</span>
<i class="icon-[comfy--workflow] size-4" />
</button>
<button
v-else-if="showAdvancedState || showAdvancedInputsButton"
@click.stop="showAdvancedState = !showAdvancedState"
>
<template v-if="showAdvancedState">
<span>{{ t('rightSidePanel.hideAdvancedInputsButton') }}</span>
<i class="icon-[lucide--chevron-up] size-4" />
</template>
<template v-else>
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }} </span>
<i class="icon-[lucide--settings-2] size-4" />
</template>
</button>
</Button>
<template v-if="!isCollapsed && nodeData.resizable !== false">
<div
v-for="handle in RESIZE_HANDLES"
:key="handle.corner"
role="button"
:aria-label="t(handle.i18nKey)"
:class="
cn(
baseResizeHandleClasses,
handle.positionClasses,
handle.cursorClass,
'group-hover/node:opacity-100'
)
"
@pointerdown.stop="handleResizePointerDown($event, handle.corner)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
:class="cn('w-2/5 h-2/5 absolute', handle.svgPositionClasses)"
:style="
handle.svgTransform ? { transform: handle.svgTransform } : undefined
"
>
<path
d="M11 1L1 11M11 6L6 11"
stroke="var(--color-muted-foreground)"
stroke-width="0.975"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import {
computed,
customRef,
nextTick,
onErrorCaptured,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import {
LGraphCanvas,
LGraphEventMode,
LiteGraph,
RenderShape
} from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
import { LayoutSource } from '@/renderer/core/layout/types'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { usePartitionedBadges } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isTransparent } from '@/utils/colorUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { RESIZE_HANDLES } from '../interactions/resize/resizeHandleConfig'
import { useNodeResize } from '../interactions/resize/useNodeResize'
import LivePreview from './LivePreview.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 {
nodeData: VueNodeData
error?: string | null
}
const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const { t } = useI18n()
const settingStore = useSettingStore()
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
useNodeEventHandlers()
const { bringNodeToFront } = useNodeZIndex()
useVueElementTracking(() => nodeData.id, 'node')
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isSelected = computed(() => {
return selectedNodeIds.value.has(nodeData.id)
})
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
const executionStore = useExecutionStore()
const hasExecutionError = computed(
() => executionStore.lastExecutionErrorNodeId === nodeData.id
)
const hasAnyError = computed((): boolean => {
return !!(
hasExecutionError.value ||
nodeData.hasErrors ||
error ||
(executionStore.lastNodeErrors?.[nodeData.id]?.errors.length ?? 0) > 0
)
})
const displayHeader = computed(() => nodeData.titleMode !== TitleMode.NO_TITLE)
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
const bypassed = computed(
(): boolean => nodeData.mode === LGraphEventMode.BYPASS
)
const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER)
const nodeOpacity = computed(() => {
const globalOpacity = settingStore.get('Comfy.Node.Opacity') ?? 1
if (nodeData.flags?.ghost) return globalOpacity * 0.3
// For muted/bypassed nodes, apply the 0.5 multiplier on top of global opacity
if (bypassed.value || muted.value) {
return globalOpacity * 0.5
}
return globalOpacity
})
const hasInputs = computed(() => nonWidgetedInputs(nodeData).length > 0)
const hasOutputs = computed((): boolean => !!nodeData.outputs?.length)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
// 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
})
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()
const badges = usePartitionedBadges(nodeData)
async function nodeOnPointerdown(event: PointerEvent) {
if (event.altKey && lgraphNode.value) {
const result = LGraphCanvas.cloneNodes([lgraphNode.value])
if (result?.created?.length) {
const [newNode] = result.created
startDrag(event, `${newNode.id}`)
layoutStore.isDraggingVueNodes.value = true
await nextTick()
bringNodeToFront(`${newNode.id}`)
return
}
}
onPointerdown(event)
}
// Handle right-click context menu
const handleContextMenu = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
// First handle the standard right-click behavior (selection)
handleNodeRightClick(event as PointerEvent, nodeData.id)
// Show the node options menu at the cursor position
showNodeOptions(event)
}
/**
* Set initial DOM size from layout store.
*/
function initSizeStyles() {
const el = nodeContainerRef.value
const { width, height } = size.value
if (!el) return
const suffix = isCollapsed.value ? '-x' : ''
const fullHeight = height + LiteGraph.NODE_TITLE_HEIGHT
el.style.setProperty(`--node-width${suffix}`, `${width}px`)
el.style.setProperty(`--node-height${suffix}`, `${fullHeight}px`)
}
/**
* Handle external size changes (e.g., from extensions calling node.setSize()).
* Updates CSS variables when layoutStore changes from Canvas/External source.
*/
function handleLayoutChange(change: {
source: LayoutSource
nodeIds: string[]
}) {
// Only handle Canvas or External source (extensions calling setSize)
if (
change.source !== LayoutSource.Canvas &&
change.source !== LayoutSource.External
)
return
if (!change.nodeIds.includes(nodeData.id)) return
if (layoutStore.isResizingVueNodes.value) return
if (isCollapsed.value) return
const el = nodeContainerRef.value
if (!el) return
const newSize = size.value
const fullHeight = newSize.height + LiteGraph.NODE_TITLE_HEIGHT
el.style.setProperty('--node-width', `${newSize.width}px`)
el.style.setProperty('--node-height', `${fullHeight}px`)
}
let unsubscribeLayoutChange: (() => void) | null = null
onMounted(() => {
initSizeStyles()
unsubscribeLayoutChange = layoutStore.onChange(handleLayoutChange)
})
onUnmounted(() => {
unsubscribeLayoutChange?.()
})
const baseResizeHandleClasses =
'absolute h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
const MIN_NODE_WIDTH = 225
const mutations = useLayoutMutations()
const { startResize } = useNodeResize((result, element) => {
if (isCollapsed.value) return
// Clamp width to minimum to avoid conflicts with CSS min-width
const clampedWidth = Math.max(result.size.width, MIN_NODE_WIDTH)
// Apply size directly to DOM element - ResizeObserver will pick this up
element.style.setProperty('--node-width', `${clampedWidth}px`)
element.style.setProperty('--node-height', `${result.size.height}px`)
// Update position for non-SE corner resizing
if (result.position) {
mutations.setSource(LayoutSource.Vue)
mutations.moveNode(nodeData.id, result.position)
}
})
const handleResizePointerDown = (
event: PointerEvent,
corner: CompassCorners
) => {
if (event.button !== 0) return
if (!shouldHandleNodePointerEvents.value) return
if (nodeData.flags?.pinned) return
if (nodeData.resizable === false) return
startResize(event, corner)
}
watch(isCollapsed, (collapsed) => {
const element = nodeContainerRef.value
if (!element) return
const [from, to] = collapsed ? ['', '-x'] : ['-x', '']
const currentWidth = element.style.getPropertyValue(`--node-width${from}`)
element.style.setProperty(`--node-width${to}`, currentWidth)
element.style.setProperty(`--node-width${from}`, '')
const currentHeight = element.style.getPropertyValue(`--node-height${from}`)
element.style.setProperty(`--node-height${to}`, currentHeight)
element.style.setProperty(`--node-height${from}`, '')
})
// Check if node has custom content (like image/video outputs)
const hasCustomContent = computed(() => {
// Show custom content if node has media outputs
return !!nodeMedia.value && nodeMedia.value.urls.length > 0
})
// Computed classes and conditions for better reusability
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
() => nodeData.id,
{
isCollapsed
}
)
const borderClass = computed(() => {
if (hasAnyError.value) return 'border-node-stroke-error bg-error'
//FIXME need a better way to detecting transparency
if (
!displayHeader.value &&
nodeData.bgcolor &&
isTransparent(nodeData.bgcolor)
)
return 'border-0'
return ''
})
const outlineClass = computed(() => {
return cn(
isSelected.value && 'outline-node-component-outline',
hasAnyError.value && 'outline-node-stroke-error',
executing.value && 'outline-node-stroke-executing'
)
})
const cursorClass = computed(() => {
return cn(
nodeData.flags?.pinned
? 'cursor-default'
: layoutStore.isDraggingVueNodes.value
? 'cursor-grabbing'
: 'cursor-grab'
)
})
const shapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'rounded-none'
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
default:
return 'rounded-2xl'
}
})
const beforeShapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'before:rounded-none'
case RenderShape.CARD:
return 'before:rounded-tl-2xl before:rounded-br-2xl before:rounded-tr-none before:rounded-bl-none'
default:
return 'before:rounded-2xl'
}
})
// Event handlers
const handleCollapse = () => {
handleNodeCollapse(nodeData.id, !isCollapsed.value)
}
const handleHeaderTitleUpdate = (newTitle: string) => {
handleNodeTitleUpdate(nodeData.id, newTitle)
}
const handleEnterSubgraph = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_node_open_subgraph_clicked'
})
const graph = app.rootGraph
if (!graph) {
console.warn('LGraphNode: No graph available for subgraph navigation')
return
}
const locatorId = getLocatorIdFromNodeData(nodeData)
const litegraphNode = getNodeByLocatorId(graph, locatorId)
if (!litegraphNode?.isSubgraphNode() || !('subgraph' in litegraphNode)) {
console.warn('LGraphNode: Node is not a valid subgraph node', litegraphNode)
return
}
const canvas = app.canvas
if (!canvas || typeof canvas.openSubgraph !== 'function') {
console.warn('LGraphNode: Canvas or openSubgraph method not available')
return
}
canvas.openSubgraph(litegraphNode.subgraph, litegraphNode)
}
const nodeOutputs = useNodeOutputStore()
const nodeOutputLocatorId = computed(() =>
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
)
const lgraphNode = computed(() => {
const locatorId = getLocatorIdFromNodeData(nodeData)
return getNodeByLocatorId(app.rootGraph, locatorId)
})
const showAdvancedInputsButton = computed(() => {
const node = lgraphNode.value
if (!node) return false
// For subgraph nodes: check for unpromoted widgets
if (node instanceof SubgraphNode) {
const interiorNodes = node.subgraph.nodes
const allInteriorWidgets = interiorNodes.flatMap((n) => n.widgets ?? [])
return allInteriorWidgets.some((w) => !w.computedDisabled && !w.promoted)
}
// For regular nodes: show button if there are advanced widgets and they're currently hidden
const hasAdvancedWidgets = nodeData.widgets?.some((w) => w.options?.advanced)
const alwaysShowAdvanced = settingStore.get(
'Comfy.Node.AlwaysShowAdvancedWidgets'
)
return hasAdvancedWidgets && !alwaysShowAdvanced
})
const showAdvancedState = customRef((track, trigger) => {
let internalState = false
const node = lgraphNode.value
if (node && !(node instanceof SubgraphNode)) {
internalState = !!node.showAdvanced
}
return {
get() {
track()
return internalState
},
set(value: boolean) {
const node = lgraphNode.value
if (!node) return
if (node instanceof SubgraphNode) {
// Do not modify internalState for subgraph nodes
const rightSidePanelStore = useRightSidePanelStore()
if (value) {
rightSidePanelStore.focusSection('advanced-inputs')
} else {
rightSidePanelStore.closePanel()
}
} else {
node.showAdvanced = value
internalState = value
}
trigger()
}
}
})
const hasVideoInput = computed(() => {
return (
lgraphNode.value?.inputs?.some((input) => input.type === 'VIDEO') ?? false
)
})
const nodeMedia = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const node = lgraphNode.value
if (!node || !newOutputs?.images?.length || node.hideOutputImages)
return undefined
const urls = nodeOutputs.getNodeImageUrls(node)
if (!urls?.length) return undefined
const type =
node.previewMediaType === 'video' ||
(!node.previewMediaType && hasVideoInput.value)
? 'video'
: 'image'
return { type, urls } as const
})
const nodeContainerRef = ref<HTMLDivElement>()
// Drag and drop support
const isDraggingOver = ref(false)
function handleDragOver(event: DragEvent) {
const node = lgraphNode.value
if (!node || !node.onDragOver) {
isDraggingOver.value = false
return
}
// Call the litegraph node's onDragOver callback to check if files are valid
const canDrop = node.onDragOver(event)
isDraggingOver.value = canDrop
}
function handleDragLeave() {
isDraggingOver.value = false
}
async function handleDrop(event: DragEvent) {
isDraggingOver.value = false
const node = lgraphNode.value
if (!node || !node.onDragDrop) {
return
}
// Forward the drop event to the litegraph node's onDragDrop callback
await node.onDragDrop(event)
}
</script>