Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/components/NodeWidgets.vue
Terry Jia 9f9fa60137 feat: reactive upstream value display for disabled curve and imagecrop widgets (#9851)
Replacement for https://github.com/Comfy-Org/ComfyUI_frontend/pull/9364

## Summary
Add a generic mechanism for widgets to reactively display upstream
linked values when disabled, with concrete implementations for curve and
imagecrop widgets.

## Changes

- What: When a widget input is linked to an upstream node, the widget
enters a disabled state and displays the upstream node's current value
reactively. This is built as a two-layer system:
- Infrastructure layer: Resolve link origin info (originNodeId,
originOutputName) in buildSlotMetadata, pass it through
SimplifiedWidget.linkedUpstream, and provide a generic useUpstreamValue
composable that reads upstream values from widgetValueStore.
- Widget layer: Each widget type provides its own ValueExtractor to
interpret upstream data. singleValueExtractor handles simple
type-matched values (e.g. CurvePoint[]); boundsExtractor composes a
Bounds object from either a single upstream widget or four individual
x/y/width/height number widgets.
- Curve widget: shows upstream curve points in read-only mode
- ImageCrop widget: shows upstream bounding box with disabled crop
handles and number inputs
- CurveEditor and WidgetBoundingBox: gain disabled prop support

## Adapting future widgets
The system is designed so that any widget needing upstream value display
only needs to:
1. Accept widget: SimplifiedWidget as a prop (provides linkedUpstream
automatically)
2. Call useUpstreamValue(() => widget.linkedUpstream, extractor) with a
suitable extractor
3. Use singleValueExtractor(typeGuard) for single-value types, or write
a custom ValueExtractor for composite cases like boundsExtractor
4. Compute an effectiveValue that switches between upstream and local
based on disabled state

No infrastructure changes are needed — linkedUpstream is already
populated for all widget types that have a corresponding input slot.

## Review Focus
- The buildSlotMetadata helper is shared between extractVueNodeData and
refreshNodeSlots — verify the graph ref is reliably available in both
paths
- boundsExtractor composing from 4 individual number widgets
(x/y/width/height) — this handles the BBox→ImageCrop case where upstream
exposes separate widgets rather than a single Bounds object

## Screenshots

https://github.com/user-attachments/assets/dbc57a44-c5df-44f0-acce-d347797ee8fb

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9851-feat-reactive-upstream-value-display-for-disabled-curve-and-imagecrop-widgets-3226d73d36508134b386ddc9b9f1266b)
by [Unito](https://www.unito.io)
2026-03-13 16:59:53 -04:00

378 lines
12 KiB
Vue

<template>
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ st('nodeErrors.widgets', 'Node Widgets Error') }}
</div>
<div
v-else
:class="
cn(
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3',
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
)
"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
}"
@pointerdown.capture="handleBringToFront"
@pointerdown="handleWidgetPointerEvent"
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
>
<template
v-for="(widget, index) in processedWidgets"
:key="`widget-${index}-${widget.name}`"
>
<div
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
>
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:slot-data="{
name: widget.name,
type: widget.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<AppInput
:id="widget.id"
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed, onErrorCaptured, ref, toValue } from 'vue'
import type { Component } from 'vue'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
// Import widget components directly
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type {
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
import { app } from '@/scripts/app'
import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
nodeData?: VueNodeData
}
const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { isSelectInputsMode } = useAppMode()
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
event.stopPropagation()
forwardEventToCanvas(event)
}
function handleBringToFront() {
if (nodeData?.id != null) {
bringNodeToFront(String(nodeData.id))
}
}
const { handleNodeRightClick } = useNodeEventHandlers()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
const canSelectInputs = computed(
() =>
isSelectInputsMode.value &&
nodeData?.mode === LGraphEventMode.ALWAYS &&
nodeTypeValidForApp(nodeData.type) &&
!nodeData.hasErrors
)
const nodeType = computed(() => nodeData?.type || '')
const settingStore = useSettingStore()
const showAdvanced = computed(
() =>
nodeData?.showAdvanced ||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
nodeType.value
)
const widgetValueStore = useWidgetValueStore()
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
nodeExecId: string,
widgetOptions: IWidgetOptions | Record<string, never>
): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
executionErrorStore.clearWidgetRelatedErrors(
effectiveExecId,
widget.slotName ?? widget.name,
widget.name,
newValue,
{ min: widgetOptions?.min, max: widgetOptions?.max }
)
}
}
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
id: string
name: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: string,
nodeErrors: { errors: { extra_info?: { input_name?: string } }[] } | undefined
): boolean {
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
const inputName = widget.slotName ?? widget.name
return (
!!errors?.some((e) => e.extra_info?.input_name === inputName) ||
missingModelStore.isWidgetMissingModel(
widget.sourceExecutionId ?? nodeExecId,
widget.name
)
)
}
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
// nodeData.id is the local node ID; subgraph nodes need the full execution
// path (e.g. "65:63") to match keys in lastNodeErrors.
const nodeExecId = app.rootGraph
? getExecutionIdFromNodeData(app.rootGraph, nodeData)
: String(nodeData.id ?? '')
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
const graphId = canvasStore.canvas?.graph?.rootGraph.id
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
for (const widget of widgets) {
if (!shouldRenderAsVue(widget)) continue
const isPromotedView = !!widget.nodeId
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata } = widget
// Get metadata from store (registered during BaseWidget.setNodeId)
const bareWidgetId = stripGraphPrefix(
widget.storeNodeId ?? widget.nodeId ?? nodeId
)
const storeWidgetName = widget.storeName ?? widget.name
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined
// Get value from store (falls back to undefined if not registered)
const value = widgetState?.value as WidgetValue
// Build options from store state, with disabled override for
// slot-linked widgets or widgets with disabled state (e.g. display-only)
const storeOptions = widgetState?.options ?? {}
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...storeOptions, disabled: true }
: storeOptions
const borderStyle =
graphId &&
!isPromotedView &&
promotionStore.isPromotedByAny(graphId, String(bareWidgetId), widget.name)
? 'ring ring-component-node-widget-promoted'
: widget.options?.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
linkedUpstream,
options: widgetOptions,
spec: widget.spec
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
widget,
nodeExecId,
widgetOptions
)
const tooltipText = getWidgetTooltip(widget)
const tooltipConfig = createTooltipConfig(tooltipText)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
: undefined
)
}
result.push({
advanced: widget.options?.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(widget, nodeExecId, nodeErrors),
hidden: widget.options?.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,
type: widget.type,
vueComponent,
simplified,
value,
updateHandler,
tooltipConfig,
slotMetadata
})
}
return result
})
const gridTemplateRows = computed((): string => {
// Use processedWidgets directly since it already has store-based hidden/advanced
return toValue(processedWidgets)
.filter((w) => !w.hidden && (!w.advanced || showAdvanced.value))
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
.join(' ')
})
</script>